diff --git a/docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst b/docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst index d870b218..6697772d 100644 --- a/docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst +++ b/docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst @@ -4,7 +4,7 @@ Status ****** -**Draft** +**Accepted** Context ******* @@ -15,7 +15,7 @@ Context `PR #361`_ attempted to enforce that full truth table directly inside ``PermissionValidationMeView`` and other REST API endpoints, checking the flag per scope on every request. Per `PR #361's own comment thread`_, those endpoints are release-blocking for Verawood, so baking precise per-scope flag logic into them risked correctness and performance on critical paths without enough test coverage across the framework to be confident in time for the release. That approach was reverted, and the team pivoted to `issue #358`_ instead, exposing the flag's raw state through a dedicated endpoint and letting the admin-console MFE apply the simpler #340/#341 rule itself, deferring precise per-scope filtering to a later cycle. -Neither edx-toggles nor edx-platform expose a suitable public API for this client-facing use case. ``/api/toggles/v0/state/`` (`edx_toggles source`_) can expose override data, but it requires Django staff/admin access and exposes flag state broadly (not just ``authz.enable_course_authoring``). ``WaffleFlagOrgOverrideModel.override_value(name, key)`` and its course-level counterpart (`waffle_utils models source`_) each answer for one specific org or course, not "which orgs/courses have an override." +Neither edx-toggles nor openedx-platform expose a suitable public API to consume the flag's state for this use case. ``openedx/core/djangoapps/waffle_utils/views.py``'s ``ToggleStateView``, built on edx-toggles' reporting machinery (`edx_toggles source`_) and wired at ``/api/toggles/v0/state/``, requires ``IsStaff`` and reports the state of every registered toggle at once. ``WaffleFlagOrgOverrideModel.override_value(name, key)`` and its course-level counterpart (`waffle_utils models source`_) each require already knowing which specific org or course to check. Decision ******** @@ -31,8 +31,10 @@ Consequences #. **Release-blocking endpoints stay untouched.** ``PermissionValidationMeView`` and the other endpoints named in PR #361 keep their existing behavior. This endpoint is additive, isolated, low-risk. #. **One place answers "what's the flag's state right now."** ``get_waffle_flag_states()`` centralizes the lookup, reusing ``enable_authz_course_authoring()`` for the global tier and querying ``WaffleFlagOrgOverrideModel``/``WaffleFlagCourseOverrideModel`` directly for the org/course tiers, since no public API answers "which orgs/courses have an override." #. **The MFE bears the filtering complexity.** Applying the #340/#341 "any tier on" rule, and any future precise per-course/per-org filtering, is MFE-side logic from here on. +#. **The response isn't scoped to the caller.** Any authenticated user gets every org/course override on the instance; there's no per-user filtering beyond ``IsAuthenticated``. Callers resolve their own course → org → global precedence from the raw lists, the way ``frontend-app-admin-console``'s ``useCourseAuthoringFlag`` hook (`frontend-app-admin-console#176`_) does. #. **These override queries scan the whole table, unfiltered by any specific org/course.** For instances with many overrides, this is a full-table read on every call. Not a problem at current scale, but worth revisiting if usage grows (see `issue #360`_). -#. **``openedx_authz.utils`` now depends on** ``common.djangoapps.student.roles.enable_authz_course_authoring`` **and** ``openedx.core.djangoapps.waffle_utils.models``, guarded by the same standalone-import pattern already used elsewhere in this repo (``rest_api/utils.py``, ``handlers.py``). This is a temporary, direct edx-platform dependency, tracked as follow-up work under `issue #360`_ (moving the dependency direction so services depend on ``openedx_authz``). +#. **``openedx_authz.utils`` now depends on** ``common.djangoapps.student.roles.enable_authz_course_authoring`` **and** ``openedx.core.djangoapps.waffle_utils.models``, guarded by the same standalone-import pattern already used elsewhere in this repo (``rest_api/utils.py``, ``handlers.py``). This is a temporary, direct openedx-platform dependency, tracked as follow-up work under `issue #360`_ (moving the dependency direction so services depend on ``openedx_authz``). +#. **The exception has a planned end.** `Issue #377`_ tracks removing ``WaffleFlagStatesAPIView`` and ``get_waffle_flag_states()`` when ``authz.enable_course_authoring`` is deprecated. The flag's ``toggle_target_removal_date`` is 2027-06-09, tracked upstream at `openedx-platform#37927`_. Rejected Alternatives ********************** @@ -41,21 +43,40 @@ Rejected Alternatives Correctness and performance across the whole framework weren't validated in time for a release-blocking change, per PR #361's own comment thread. The simpler #340/#341 rule doesn't need per-scope precision to ship. **Relying on** ``/api/toggles/v0/state/`` - This edx-toggles endpoint can expose override data, but it requires Django staff/admin access and is not suitable for this use case. It also exposes flag state broadly (not just ``authz.enable_course_authoring``), which is a security risk for this use case. + openedx-platform already exposes a generic toggle-state endpoint, ``ToggleStateView`` in ``openedx/core/djangoapps/waffle_utils/views.py``, wired at this URL. Its ``permission_classes`` require ``IsStaff``, which the admin-console MFE's calling user is not guaranteed to be, and it reports the state of every registered toggle at once. + +**Building a replacement endpoint outside openedx-authz now, and migrating the admin-console MFE to it.** + Authorization owns endpoints that expose roles, permissions, assignments, or scopes; a course-authoring flag's raw state is none of those, so this endpoint sits outside authorization's domain. But knowing it doesn't belong here doesn't tell us where it does belong, and that question is out of scope for this ADR. Moving the endpoint now would require a new owner, a new endpoint, and a migration in the admin-console MFE. That work is not justified for a flag with a ``toggle_target_removal_date`` of 2027-06-09 (tracked upstream at `openedx-platform#37927`_; see `Issue #377`_). If the flag remains in use past that date, revisit this alternative. + +Addendum (2026-07-28) +********************* + +`ADR 0016`_ audits every endpoint in ``openedx_authz.rest_api`` by domain vocabulary. Authorization is a supporting subdomain (`ADR 0018 in openedx-events`_), the same tier as Analytics, consumed across learning, content authoring, and enterprise. Domain ownership (does the endpoint expose roles, permissions, assignments, or scopes?) and package placement (is it reusable or tailored to one workflow?) are separate questions. An Admin Console screen aggregating tasks from several domains isn't itself a domain; each task still belongs to whichever domain owns it. + +``WaffleFlagStatesAPIView`` fails the domain-ownership question, since a course-authoring flag's state isn't authorization data. ADR 0016 makes that a standing rule. Authorization endpoints must not compute or expose another domain's data, and this is the sole, named exception, kept here rather than moved elsewhere. The exception doesn't extend to any other endpoint. + +Whether these reusable endpoints would ever become flag-aware remained open until ADR 0016 settled it as a domain-boundary rule that doesn't depend on release timing. ``PermissionValidationMeView``, ``RoleUserAPIView``, and ``RoleListView`` must not depend on the course-authoring flag. Any future flag-aware behavior for these endpoints belongs to whichever domain ends up owning the flag. References ********** * `ADR 0010`_ +* `ADR 0016`_ +* `ADR 0018 in openedx-events`_ * `Issue #340`_ * `Issue #341`_ * `Issue #358`_ * `Issue #360`_ +* `Issue #377`_ * `PR #361`_ * `PR #361's own comment thread`_ * `A review comment on frontend-app-admin-console#176`_ +* `frontend-app-admin-console#176`_ +* `openedx-platform#37927`_ .. _ADR 0010: 0010-course-authoring-flag.rst +.. _ADR 0016: 0016-rest-api-domain-ownership-boundary.rst +.. _ADR 0018 in openedx-events: https://github.com/openedx/openedx-events/blob/main/docs/decisions/0018-supporting-subdomain-modules.rst .. _Issue #340: https://github.com/openedx/openedx-authz/issues/340 .. _issue #340: https://github.com/openedx/openedx-authz/issues/340 .. _Issue #341: https://github.com/openedx/openedx-authz/issues/341 @@ -64,8 +85,11 @@ References .. _issue #358: https://github.com/openedx/openedx-authz/issues/358 .. _Issue #360: https://github.com/openedx/openedx-authz/issues/360 .. _issue #360: https://github.com/openedx/openedx-authz/issues/360 +.. _Issue #377: https://github.com/openedx/openedx-authz/issues/377 .. _PR #361: https://github.com/openedx/openedx-authz/pull/361 .. _PR #361's own comment thread: https://github.com/openedx/openedx-authz/pull/361#issuecomment-4967053225 .. _A review comment on frontend-app-admin-console#176: https://github.com/openedx/frontend-app-admin-console/pull/176#issuecomment-4900922914 +.. _frontend-app-admin-console#176: https://github.com/openedx/frontend-app-admin-console/pull/176 .. _edx_toggles source: https://github.com/openedx/edx-toggles/blob/master/edx_toggles/toggles/state/internal/report.py -.. _waffle_utils models source: https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/waffle_utils/models.py +.. _waffle_utils models source: https://github.com/openedx/openedx-platform/blob/master/openedx/core/djangoapps/waffle_utils/models.py +.. _openedx-platform#37927: https://github.com/openedx/openedx-platform/issues/37927 diff --git a/docs/decisions/0016-rest-api-domain-ownership-boundary.rst b/docs/decisions/0016-rest-api-domain-ownership-boundary.rst new file mode 100644 index 00000000..3a4fa0ec --- /dev/null +++ b/docs/decisions/0016-rest-api-domain-ownership-boundary.rst @@ -0,0 +1,68 @@ +0016: REST API Domain Ownership Boundary +######################################### + +Status +****** + +**Draft** + +Context +******* + +``openedx_authz.rest_api`` contains both reusable authorization endpoints and endpoints tailored to a specific Admin Console workflow. That difference in audience and shape does not, by itself, determine which domain owns an endpoint. Issue #360 raised the need for a clearer boundary after `PR #361`_ proposed making ``PermissionValidationMeView`` inspect a course-authoring flag stored in openedx-platform. + +An audit of all ten endpoints in ``openedx_authz.rest_api.v1`` classified each one against Open edX's existing domain vocabulary, instead of by who calls it today: + +- The `edX DDD Bounded Contexts`_ documentation classifies Open edX's subdomains as core, supporting, or generic. +- `ADR 0018 in openedx-events`_ classifies authorization as a supporting subdomain, the same tier as Analytics. Both are independent of any single application, and both are consumed across learning, content authoring, enterprise, and other areas. ADR 0018 also establishes that a UI surface aggregating tasks from several domains, such as an admin console, is not itself a domain: "'Admin' describes a user role and an interface where tasks from multiple domains are aggregated, the tasks themselves belong to their respective domains." + +The audit therefore separates two independent questions: + +- **Domain ownership:** Does the endpoint expose authorization data, such as roles, permissions, assignments, or scopes? +- **Package placement:** Is the endpoint reusable, or is it tailored to an Admin Console workflow? + +Seven of the ten endpoints expose authorization data. Their domain ownership does not change when an Admin Console-specific response shape makes a separate package useful. ``WaffleFlagStatesAPIView`` exposes the state of a course-authoring flag instead, and this ADR does not need to decide which domain should own that flag; it only establishes that authorization does not. The ownership of ``UserValidationAPIView`` and ``AdminConsoleOrgsAPIView`` also remains unresolved because it is not necessary to settle the flag boundary. + +Decision +******** + +1. Authorization owns endpoints that expose roles, permissions, assignments, or scopes. This remains true whether an endpoint is reusable or tailored to one screen. +2. The five endpoints tailored to Admin Console workflows (``AdminConsoleOrgsAPIView``, ``ScopesAPIView``, ``TeamMembersAPIView``, ``TeamMemberAssignmentsAPIView``, and ``AssignmentsAPIView``) move to ``openedx_authz/rest_api/v1/admin_console/``. This is a code-organization boundary, not a new domain boundary. +3. The reusable endpoints ``PermissionValidationMeView``, ``RoleUserAPIView``, and ``RoleListView`` remain in ``openedx_authz/rest_api/v1/views.py``. Their guarantee is that they answer "what can this subject do" identically regardless of caller. Depending on another domain's concept, the course-authoring flag included, would make that answer depend on who's asking, breaking the guarantee for every other consumer. Any consumer that needs flag-aware behavior handles it on its own side, the way ``authz_permission_required`` already does in CMS. +4. Authorization endpoints must not compute or expose data owned by another domain. ``WaffleFlagStatesAPIView`` is the sole current exception. `ADR 0015`_ documents why it will remain temporarily; the exception must not be extended to other endpoints. +5. This ADR does not decide the domain ownership of ``UserValidationAPIView`` or ``AdminConsoleOrgsAPIView``. They retain the package placement described above until that question is resolved separately. + +Consequences +************ + +1. Future proposals cannot add another domain's logic or data to an authorization endpoint without revisiting this decision. +2. Admin Console-specific endpoints have a clear package boundary without treating the Admin Console as a domain. +3. The ownership of ``UserValidationAPIView`` and ``AdminConsoleOrgsAPIView`` remains open. +4. The package move does not change any URLs, so ``frontend-app-admin-console`` is unaffected. +5. This holds even while openedx-authz has few consumers and one exception looks cheap. `openedx_catalog`_, a generic library in openedx-core, defers the same kind of decision rather than building it early. Its API docstring says it "does not yet provide any 'list courses' methods" because visibility depends on "instance-specific logic (e.g. enterprise, subscriptions, white labelling)," left for a future pluggable extension point instead of the generic API absorbing it now. Taking on one consumer's condition today adds a responsibility the framework doesn't own, and makes the API harder to keep generic as more consumers arrive. + +Rejected Alternatives +********************* + +**Treating the Admin Console as its own domain, on par with authorization or Course Authoring.** +Per `ADR 0018 in openedx-events`_, a UI surface that aggregates tasks from several domains is not itself a domain; the tasks it aggregates belong to whichever domain already owns them. Classifying the Admin Console as a domain would misattribute ownership of tasks (role assignment, permission checks) that belong to authorization, and introduce vocabulary the rest of the Open edX architecture doesn't use. + +**Moving the five Admin Console endpoints out of openedx-authz entirely, into a different repository or application.** +Their data (roles, permissions, assignments, scopes) is authorization's own. A non-reusable shape is a package-organization concern, not a reason to relocate authorization's own data to a different codebase. + +References +********** + +* `edX DDD Bounded Contexts`_ +* `ADR 0018 in openedx-events`_ +* `ADR 0015`_ +* `Issue #360`_ +* `PR #361`_ +* `openedx_catalog`_ + +.. _edX DDD Bounded Contexts: https://openedx.atlassian.net/wiki/spaces/AC/pages/663224968/edX+DDD+Bounded+Contexts +.. _ADR 0018 in openedx-events: https://github.com/openedx/openedx-events/blob/main/docs/decisions/0018-supporting-subdomain-modules.rst +.. _ADR 0015: 0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst +.. _Issue #360: https://github.com/openedx/openedx-authz/issues/360 +.. _PR #361: https://github.com/openedx/openedx-authz/pull/361 +.. _openedx_catalog: https://github.com/openedx/openedx-core/blob/main/src/openedx_catalog/api.py diff --git a/docs/decisions/0017-rest-api-package-layout-convention.rst b/docs/decisions/0017-rest-api-package-layout-convention.rst new file mode 100644 index 00000000..146a464c --- /dev/null +++ b/docs/decisions/0017-rest-api-package-layout-convention.rst @@ -0,0 +1,48 @@ +0017: Package Layout Convention for REST API Views +################################################### + +Status +****** + +**Draft** + +Context +******* + +``openedx_authz/rest_api/v1/`` started as a single ``views.py`` module holding every REST endpoint, reusable and consumer-specific alike. `ADR 0016`_ determined which endpoints expose authorization's own data (roles, permissions, assignments, scopes) and which don't, and applied that determination by moving endpoints into two new subpackages: ``admin_console/`` for the five endpoints shaped around the Admin Console's specific workflows, and ``course_authoring/`` for ``WaffleFlagStatesAPIView``, the one documented exception whose data belongs to Course Authoring. + +Both moves happened as part of applying ADR 0016's ownership rule, but the packaging convention itself was never written down on its own. This ADR names that convention so a future endpoint has a clear placement rule to apply, instead of requiring a fresh architecture discussion each time one is added. + +Decision +******** + +1. A reusable authorization endpoint, one that answers the same question identically for any caller, stays in ``rest_api/v1/views.py``. +2. An endpoint shaped around one specific consumer's workflow, whose data is still authorization's own, moves into a subpackage named after that consumer, for example ``admin_console/``. This is a package-placement decision. `ADR 0016`_ already governs which data counts as authorization's own; this rule only decides where an endpoint's code lives once that question is settled. +3. An endpoint that is a documented exception to domain ownership (`ADR 0016`_ decision item 4) moves into a subpackage named after the domain its data actually belongs to, for example ``course_authoring/``, rather than staying in ``views.py`` next to endpoints it doesn't behave like. +4. A subpackage owns its ``views.py``, plus its own ``filters.py`` or ``serializers.py`` for classes not shared with the generic module. Mixins and serializers used by both the generic module and a subpackage stay in ``rest_api/v1/serializers.py``. +5. Each subpackage's ``__init__.py`` states in one short docstring which of rule 2 or rule 3 justifies its existence, so a reader can tell why the package exists without reconstructing the reasoning from the ADRs. + +Consequences +************ + +1. A new endpoint has an immediate placement rule. Reusable, authorization's own data stays in ``views.py``. Consumer-specific, authorization's own data gets a subpackage named after that consumer. Data belonging to a different domain gets a subpackage named after that domain. +2. ``admin_console/`` and ``course_authoring/`` are the first two instances of this pattern and can be pointed to directly as examples, instead of re-deriving the reasoning from `ADR 0016`_ for every new case. +3. This ADR does not add a new domain-ownership rule. Package boundaries follow from `ADR 0016`_'s ownership determination. + +Rejected Alternatives +********************* + +**Deciding placement ad hoc for each new endpoint, without naming the rule.** +This already happened once. The ``admin_console/`` and ``course_authoring/`` moves were correct applications of `ADR 0016`_, but the general rule behind them was never written down as its own decision. Leaving it unstated risks each future endpoint getting a different, undocumented rationale for where it lives. + +**One subpackage per domain, regardless of consumer.** +Grouping every Admin-Console-shaped endpoint and every other consumer-specific endpoint into a single package by domain alone would still mix unrelated consumers together, the same problem the original ``views.py`` had. Naming a subpackage after the actual consumer keeps its audience unambiguous. + +References +********** + +* `ADR 0016`_ +* `ADR 0015`_ + +.. _ADR 0016: 0016-rest-api-domain-ownership-boundary.rst +.. _ADR 0015: 0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst diff --git a/openedx_authz/models/authz_migration.py b/openedx_authz/models/authz_migration.py index 1be9f8ce..8141a2fb 100644 --- a/openedx_authz/models/authz_migration.py +++ b/openedx_authz/models/authz_migration.py @@ -109,7 +109,6 @@ def save(self, *args, **kwargs) -> "AuthzCourseAuthoringMigrationRun": super().save(*args, **kwargs) return self - # pylint: disable=too-many-positional-arguments @classmethod def _create( cls, migration_type, scope_type, scope_key, status, metadata=None diff --git a/openedx_authz/rest_api/v1/admin_console/__init__.py b/openedx_authz/rest_api/v1/admin_console/__init__.py new file mode 100644 index 00000000..9140e794 --- /dev/null +++ b/openedx_authz/rest_api/v1/admin_console/__init__.py @@ -0,0 +1,9 @@ +"""REST API views built for the Admin Console's specific workflows. + +These endpoints operate on Authorization's own data (roles, permissions, +assignments, scopes), but their shape is built around one consumer's screens +rather than being reusable as-is by any caller. This is decision item 2 in ADR +0017 (rest-api-package-layout-convention), a consumer-specific subpackage for +data that still belongs to Authorization. See ADR 0016 for the domain-ownership +reasoning this rests on. +""" diff --git a/openedx_authz/rest_api/v1/filters.py b/openedx_authz/rest_api/v1/admin_console/filters.py similarity index 100% rename from openedx_authz/rest_api/v1/filters.py rename to openedx_authz/rest_api/v1/admin_console/filters.py diff --git a/openedx_authz/rest_api/v1/admin_console/views.py b/openedx_authz/rest_api/v1/admin_console/views.py new file mode 100644 index 00000000..cbb8c520 --- /dev/null +++ b/openedx_authz/rest_api/v1/admin_console/views.py @@ -0,0 +1,841 @@ +""" +REST API views built for the Admin Console. + +These views operate on Authorization's own data (roles, permissions, assignments, +scopes), but each is shaped around one specific Admin Console screen rather than being +a reusable, consumer-agnostic operation. See ``docs/decisions/0016-rest-api-domain-ownership-boundary.rst``. +""" + +import logging +import operator +from functools import reduce + +import edx_api_doc_tools as apidocs +from django.db.models import CharField, Q, QuerySet, Value +from django.db.models.functions import Cast +from django.http import HttpRequest +from django.utils.decorators import method_decorator +from edx_api_doc_tools import schema_for +from organizations.models import Organization +from organizations.serializers import OrganizationSerializer +from rest_framework import filters, generics, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from openedx_authz import api +from openedx_authz.api.data import ( + ContentLibraryData, + CourseOverviewData, + OrgContentLibraryGlobData, + OrgCourseOverviewGlobData, + PlatformGlobData, + UserAssignmentData, +) +from openedx_authz.api.users import ( + get_scopes_for_user_and_permission, + get_superadmin_assignments, + get_visible_role_assignments_for_user, + get_visible_user_role_assignments_filtered_by_current_user, +) +from openedx_authz.constants import permissions +from openedx_authz.models.scopes import get_content_library_model, get_course_overview_model +from openedx_authz.rest_api.data import ScopesQuerySetFields, ScopesTypeField +from openedx_authz.rest_api.decorators import authz_permissions, view_auth_classes +from openedx_authz.rest_api.v1.admin_console.filters import ( + TeamMemberAssignmentsOrderingFilter, + TeamMemberOrderingFilter, + TeamMemberSearchFilter, + UserAssignmentsOrderingFilter, + UserAssignmentsSearchFilter, +) +from openedx_authz.rest_api.v1.paginators import AuthZAPIViewPagination +from openedx_authz.rest_api.v1.permissions import AnyScopePermission +from openedx_authz.rest_api.v1.serializers import ( + ListAssignmentsQuerySerializer, + ListScopesQuerySerializer, + ListTeamMemberAssignmentsQuerySerializer, + ListTeamMembersSerializer, + ScopeSerializer, + TeamMemberAssignmentSerializer, + TeamMemberSerializer, + TeamMemberUserAssignmentSerializer, +) + +logger = logging.getLogger(__name__) + +ContentLibrary = get_content_library_model() +CourseOverview = get_course_overview_model() + + +@view_auth_classes() +@method_decorator( + authz_permissions( + [ + permissions.VIEW_LIBRARY_TEAM.identifier, + permissions.COURSES_VIEW_COURSE_TEAM.identifier, + ] + ), + name="get", +) +@schema_for( + "get", + parameters=[ + apidocs.query_parameter("search", str, description="Filter orgs by name or short_name"), + apidocs.query_parameter("page", int, description="Page number for pagination"), + apidocs.query_parameter("page_size", int, description="Number of items per page"), + ], + responses={ + status.HTTP_200_OK: OrganizationSerializer(many=True), + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + status.HTTP_403_FORBIDDEN: "The user does not have the required permissions", + }, +) +class AdminConsoleOrgsAPIView(generics.ListAPIView): + """ + API view for listing orgs + This API is used on the filters functionality on the Admin Console. + + **Endpoints** + + - GET: Retrieve all organizations + + **Query Parameters** + + - search (Optional): Search term to filter organizations by name or short name + - page (Optional): Page number for pagination + - page_size (Optional): Number of items per page + + **Response Format** + + Returns a paginated list of organization objects, each containing: + + - id: The organization's ID + - name: The organization's name + - short_name: The organization's short name + + **Authentication and Permissions** + + - Requires authenticated user. + + **Example Request** + + GET /api/authz/v1/orgs/?search=edx&page=1&page_size=10 + + **Example Response**:: + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "created": "2026-04-02T19:30:36.779095Z", + "modified": "2026-04-02T19:30:36.779095Z", + "name": "OpenedX", + "short_name": "OpenedX", + "description": "", + "logo": null, + "active": true + } + ] + } + """ + + serializer_class = OrganizationSerializer + pagination_class = AuthZAPIViewPagination + filter_backends = [filters.SearchFilter] + search_fields = ["name", "short_name"] + permission_classes = [AnyScopePermission] + + def get_queryset(self) -> QuerySet: + """Return active organizations ordered by name.""" + return Organization.objects.filter(active=True).order_by("name") + + +@view_auth_classes() +@method_decorator( + authz_permissions( + [ + permissions.VIEW_LIBRARY_TEAM.identifier, + permissions.COURSES_VIEW_COURSE_TEAM.identifier, + ] + ), + name="get", +) +@schema_for( + "get", + parameters=[ + apidocs.query_parameter("search", str, description="Filter scopes by display name"), + apidocs.query_parameter("org", str, description="Filter scopes by org"), + apidocs.query_parameter( + "orgs", str, description="Filter scopes by multiple orgs (comma separated list of orgs)" + ), + apidocs.query_parameter("page", int, description="Page number for pagination"), + apidocs.query_parameter("page_size", int, description="Number of items per page"), + apidocs.query_parameter( + "management_permission_only", + bool, + description=( + "If true, returns only scopes to which the calling user has manage team permission, " + "otherwise, returns any scope to which the user has view team permission." + ), + ), + apidocs.query_parameter( + "scope_type", + str, + description="Filter by scope type. Either 'course' or 'library'. Returns both if not specified.", + ), + ], + responses={ + status.HTTP_200_OK: ScopeSerializer(many=True), + status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + status.HTTP_403_FORBIDDEN: "The user does not have the required permissions", + }, +) +class ScopesAPIView(generics.ListAPIView): + """ + API view for listing scopes + This API is used on the filters and assign roles functionality on the Admin Console. + + **Endpoints** + + - GET: Retrieve all scopes + + **Query Parameters** + + - search (Optional): Search term to filter scopes by display name + - org (Optional): Filter scopes by org + - orgs (Optional): Filter scopes by multiple orgs (comma separated list of orgs) + - page (Optional): Page number for pagination + - page_size (Optional): Number of items per page + - scope_type (Optional): Filter scopes by type. Supported values are `course` and `library`. + - management_permission_only (Optional): Filter scopes either by only the ones to which the user has "manage team" + permissions (if true), or just "view team" permissions. + + **Response Format** + + Returns a paginated list of scope objects, each containing: + + - external_key: The scope external key + - display_name: The scope's name + - org: The organization serialized object + + **Authentication and Permissions** + + - Requires authenticated user with either a content library or course view team permission. + + **Example Request** + + GET /api/authz/v1/scopes/?search=edx&page=1&page_size=10 + + **Example Response**:: + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "external_key": "course-v1:OpenedX+DemoX+DemoCourse", + "display_name": "Open edX Demo Course", + "org": { + "id": 1, + "created": "2026-04-02T19:30:36.779095Z", + "modified": "2026-04-02T19:30:36.779095Z", + "name": "OpenedX", + "short_name": "OpenedX", + "description": "", + "logo": null, + "active": true + } + }, + ] + } + """ + + serializer_class = ScopeSerializer + pagination_class = AuthZAPIViewPagination + permission_classes = [AnyScopePermission] + + # Priority for fields used for stable sorting (first has more priority) + ordering_priority = ( + ScopesQuerySetFields.ORG_NAME, + ScopesQuerySetFields.SCOPE_TYPE, + ScopesQuerySetFields.DISPLAY_NAME_COL, + ScopesQuerySetFields.SCOPE_ID, + ) + + def get_serializer_context(self): + context = super().get_serializer_context() + context["org_map"] = Organization.objects.filter(active=True).in_bulk(field_name="short_name") + return context + + def _get_courses_queryset( + self, + allowed_ids: set | None = None, + allowed_orgs: set | None = None, + search: str = "", + orgs: set[str] | None = None, + ) -> QuerySet: + """Return a CourseOverview queryset projected to the unified scope shape. + + If allowed_ids and/or allowed_orgs are provided, filter to matching courses. + If search is provided, filter by display_name. + If org is provided, filter by org short_name. + """ + qs = CourseOverview.objects + if allowed_ids is not None or allowed_orgs is not None: + org_filter = Q(org__in=allowed_orgs) if allowed_orgs else Q() + id_filter = Q(id__in=allowed_ids) if allowed_ids else Q() + combined_filter = org_filter | id_filter + if not combined_filter: + qs = qs.none() + else: + qs = qs.filter(combined_filter) + if orgs: + qs = qs.filter(org__in=orgs) + if search: + qs = qs.filter(display_name__icontains=search) + return qs.annotate( + scope_id=Cast("id", output_field=CharField(db_collation="utf8mb4_unicode_ci")), + display_name_col=Cast("display_name", output_field=CharField(db_collation="utf8mb4_unicode_ci")), + org_name=Cast("org", output_field=CharField(db_collation="utf8mb4_unicode_ci")), + scope_type=Value(ScopesTypeField.COURSE, output_field=CharField(db_collation="utf8mb4_unicode_ci")), + ).values( + ScopesQuerySetFields.SCOPE_ID, + ScopesQuerySetFields.DISPLAY_NAME_COL, + ScopesQuerySetFields.ORG_NAME, + ScopesQuerySetFields.SCOPE_TYPE, + ) + + def _get_libraries_queryset( + self, + allowed_pairs: set | None = None, + allowed_orgs: set | None = None, + search: str = "", + orgs: set[str] | None = None, + ) -> QuerySet: + """Return a ContentLibrary queryset projected to the unified scope shape. + + If allowed_pairs and/or allowed_orgs are provided, filter to matching libraries. + If search is provided, filter by learning_package__title. + If org is provided, filter by org short_name. + """ + qs = ContentLibrary.objects + if allowed_pairs is not None or allowed_orgs is not None: + org_filter = Q(org__short_name__in=allowed_orgs) if allowed_orgs else Q() + pair_filter = ( + reduce(operator.or_, (Q(org__short_name=org, slug=slug) for org, slug in allowed_pairs)) + if allowed_pairs + else Q() + ) + combined = org_filter | pair_filter + if not combined: + qs = qs.none() + else: + qs = qs.filter(combined) + if orgs: + qs = qs.filter(org__short_name__in=orgs) + if search: + qs = qs.filter(learning_package__title__icontains=search) + return qs.annotate( + scope_id=Cast("slug", output_field=CharField(db_collation="utf8mb4_unicode_ci")), + display_name_col=Cast("learning_package__title", output_field=CharField(db_collation="utf8mb4_unicode_ci")), + org_name=Cast("org__short_name", output_field=CharField(db_collation="utf8mb4_unicode_ci")), + scope_type=Value(ScopesTypeField.LIBRARY, output_field=CharField(db_collation="utf8mb4_unicode_ci")), + ).values( + ScopesQuerySetFields.SCOPE_ID, + ScopesQuerySetFields.DISPLAY_NAME_COL, + ScopesQuerySetFields.ORG_NAME, + ScopesQuerySetFields.SCOPE_TYPE, + ) + + @staticmethod + def _get_allowed_scope_queryset( + *, + username: str, + scope_cls: type, + org_glob_cls: type, + get_permission: callable, + queryset_builder: callable, + extract_ids: callable, + search: str = "", + orgs: set[str] | None = None, + ) -> QuerySet: + """Resolve allowed scopes from Casbin and return a filtered queryset. + + This helper encapsulates the shared pattern of: + 1. Fetching allowed scopes for a user and permission. + 2. Partitioning them into specific IDs vs org-level globs. + 3. Delegating to the appropriate queryset builder. + + Args: + username: The username to check permissions for. + scope_cls: The concrete scope data class (e.g., CourseOverviewData). + org_glob_cls: The org-level glob class (e.g., OrgCourseOverviewGlobData). + get_permission: Callable that returns the permission for a scope class. + queryset_builder: Callable that builds the filtered queryset (e.g., _get_courses_queryset). + extract_ids: Callable that extracts specific IDs from non-glob scopes. + search: Optional search term to filter by display name. + org: Optional org short_name to filter by. + + Returns: + QuerySet: The filtered queryset projected to the unified scope shape. + """ + allowed_scopes = get_scopes_for_user_and_permission(username, get_permission(scope_cls).identifier) + + has_platform_access = any(isinstance(s, PlatformGlobData) for s in allowed_scopes) + if has_platform_access: + return queryset_builder(search=search, orgs=orgs) + + specific_scopes = [s for s in allowed_scopes if not s.IS_GLOB] + allowed_ids = extract_ids(specific_scopes) + allowed_orgs = {s.org for s in allowed_scopes if isinstance(s, org_glob_cls)} + return queryset_builder(allowed_ids, allowed_orgs, search=search, orgs=orgs) + + def _build_queryset(self, courses_qs: QuerySet | None, libraries_qs: QuerySet | None) -> QuerySet: + """Union the provided querysets and sort deterministically. + + Orders by org_name first (satisfying the 'ordered by org' requirement), then by + scope_type, display_name_col, and scope_id as tiebreakers to ensure stable pagination. + """ + if courses_qs is not None and libraries_qs is not None: + return courses_qs.union(libraries_qs).order_by(*self.ordering_priority) + qs = courses_qs if courses_qs is not None else libraries_qs + return qs.order_by(*self.ordering_priority) + + def get_queryset(self) -> QuerySet: + """Return scopes ordered by org, filtered by the user's permissions.""" + user = self.request.user + + # Validate and parse query parameters. + params_serializer = ListScopesQuerySerializer(data=self.request.query_params) + params_serializer.is_valid(raise_exception=True) + scope_type = params_serializer.validated_data["scope_type"] + search = params_serializer.validated_data["search"] + org = params_serializer.validated_data.get("org", "") + orgs_param = params_serializer.validated_data.get("orgs", []) + + orgs = set() + orgs.update(orgs_param) + + if org: + orgs.add(org) + + # Staff and superusers can see all scopes, skip permission filtering. + if user.is_staff or user.is_superuser: + return self._build_queryset( + courses_qs=( + self._get_courses_queryset(search=search, orgs=orgs) + if scope_type != ScopesTypeField.LIBRARY + else None + ), + libraries_qs=( + self._get_libraries_queryset(search=search, orgs=orgs) + if scope_type != ScopesTypeField.COURSE + else None + ), + ) + + management_only = params_serializer.validated_data["management_permission_only"] + + # Determine which permission to check based on the query parameter. + def get_permission(scope_cls): + return scope_cls.get_admin_manage_permission() if management_only else scope_cls.get_admin_view_permission() + + # Resolve allowed scopes from Casbin and build filtered querysets. + courses_qs = None + if scope_type != ScopesTypeField.LIBRARY: + courses_qs = self._get_allowed_scope_queryset( + username=user.username, + scope_cls=CourseOverviewData, + org_glob_cls=OrgCourseOverviewGlobData, + get_permission=get_permission, + queryset_builder=self._get_courses_queryset, + extract_ids=lambda scopes: {s.external_key for s in scopes}, + search=search, + orgs=orgs, + ) + + libraries_qs = None + if scope_type != ScopesTypeField.COURSE: + libraries_qs = self._get_allowed_scope_queryset( + username=user.username, + scope_cls=ContentLibraryData, + org_glob_cls=OrgContentLibraryGlobData, + get_permission=get_permission, + queryset_builder=self._get_libraries_queryset, + extract_ids=lambda scopes: { + (s.external_key.split(":")[1], s.external_key.split(":")[2]) for s in scopes + }, + search=search, + orgs=orgs, + ) + + # Union the requested querysets and sort by org at the DB level. + return self._build_queryset(courses_qs, libraries_qs) + + +@view_auth_classes() +class TeamMembersAPIView(APIView): + """ + API view for listing users in relation to role assignments. + This API is used in the Team Members section in the Admin Console. + In this context, a team member is anyone with studio access. + + **Endpoints** + + - GET: Retrieve all users that have at least one role assignment + + **Query Parameters** + + - scopes (Optional): Comma-separated list of scopes to filter by (e.g., 'lib:Org1:LIB1') + - orgs (Optional): Comma-separated list of orgs to filter by (e.g., 'Org1,Org2') + - search (Optional): Search term to filter users by username, full name, or email + - sort_by (Optional): Field to sort by. Options: username, full_name, email. Defaults to username + - order (Optional): Sort order, 'asc' or 'desc'. Defaults to asc + - page (Optional): Page number for pagination + - page_size (Optional): Number of items per page + + **Response Format** + + Returns a paginated list of team member objects, each containing: + + - username: The user's username + - full_name: The user's full name + - email: The user's email address + - assignation_count: The number of role assignments the user has + + **Authentication and Permissions** + + - Requires authenticated user. + - Results are filtered according to calling user's scope-level view permissions. + + **Example Request** + + GET /api/authz/v1/users/?orgs=Org1&search=john&sort_by=username&order=asc&page=1&page_size=10 + + **Example Response**:: + + { + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "username": "jane_doe", + "full_name": "Jane Doe", + "email": "jane_doe@example.com", + "assignation_count": 3 + }, + { + "username": "john_doe", + "full_name": "John Doe", + "email": "john_doe@example.com", + "assignation_count": 1 + } + ] + } + """ + + pagination_class = AuthZAPIViewPagination + filter_backends = [TeamMemberSearchFilter, TeamMemberOrderingFilter] + permission_classes = [AnyScopePermission] + + @apidocs.schema( + parameters=[ + apidocs.query_parameter("scopes", str, description="The scopes to query assignments for"), + apidocs.query_parameter("orgs", str, description="The orgs to query assignments for"), + apidocs.query_parameter("search", str, description="The search query to filter users by"), + apidocs.query_parameter("sort_by", str, description="The field to sort by"), + apidocs.query_parameter("order", str, description="The order to sort by"), + apidocs.query_parameter("page", int, description="Page number for pagination"), + apidocs.query_parameter("page_size", int, description="Number of items per page"), + ], + responses={ + status.HTTP_200_OK: TeamMemberSerializer(many=True), + status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + status.HTTP_403_FORBIDDEN: "The user does not have the required permissions", + }, + ) + @authz_permissions( + [ + permissions.VIEW_LIBRARY_TEAM.identifier, + permissions.COURSES_VIEW_COURSE_TEAM.identifier, + ] + ) + def get(self, request: HttpRequest) -> Response: + """Retrieve all users that have at least one assignation according to the filtering fields.""" + serializer = ListTeamMembersSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + query_params = serializer.validated_data + + users_with_assignments = get_visible_role_assignments_for_user( + orgs=query_params.get("orgs"), + scopes=query_params.get("scopes"), + allowed_for_user_external_key=request.user.username, + ) + + team_members = TeamMemberSerializer(users_with_assignments, many=True).data + for backend in self.filter_backends: + team_members = backend().filter_queryset(request, team_members, self) + + paginator = self.pagination_class() + paginated_response_data = paginator.paginate_queryset(team_members, request) + return paginator.get_paginated_response(paginated_response_data) + + +@view_auth_classes() +class TeamMemberAssignmentsAPIView(APIView): + """ + API view for listing role assignments for a specific user. + This API is used in the Team Member detail view in the Admin Console. + + **Endpoints** + + - GET: Retrieve all role assignments for a specific user + + **URL Parameters** + + - username (Required): The username of the user to retrieve assignments for + + **Query Parameters** + + - orgs (Optional): Comma-separated list of orgs to filter assignments by (e.g., 'Org1,Org2') + - roles (Optional): Comma-separated list of roles to filter assignments by (e.g., 'library_admin,library_user') + - sort_by (Optional): Field to sort by. Options: role, org, scope. Defaults to role + - order (Optional): Sort order, 'asc' or 'desc'. Defaults to asc + - page (Optional): Page number for pagination + - page_size (Optional): Number of items per page + + **Response Format** + + Returns a paginated list of assignment objects, each containing: + + - is_superadmin: Whether this entry denotes a superadmin (staff/superuser) + - role: The role name (e.g., 'library_admin', 'django.superuser') + - org: The org over which this role is applied ('*' for superadmins) + - scope: The scope over which this role is applied ('*' for superadmins) + - permission_count: The number of permissions that apply to this role (null for superadmins) + + **Authentication and Permissions** + + - Requires authenticated user. + - Results are filtered according to calling user's scope-level view permissions. + + **Example Request** + + GET + /api/authz/v1/users/john_doe/assignments/?orgs=Org1&roles=library_admin&sort_by=role&order=asc&page=1&page_size=10 + + **Example Response**:: + + { + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "is_superadmin": false, + "role": "library_admin", + "org": "Org1", + "scope": "lib:Org1:LIB1", + "permission_count": 11 + } + ] + } + """ + + pagination_class = AuthZAPIViewPagination + filter_backends = [TeamMemberAssignmentsOrderingFilter] + permission_classes = [AnyScopePermission] + + @apidocs.schema( + parameters=[ + apidocs.query_parameter("orgs", str, description="Comma-separated list of orgs to filter assignments by"), + apidocs.query_parameter("roles", str, description="Comma-separated list of roles to filter assignments by"), + apidocs.query_parameter( + "sort_by", + str, + description="The field to sort by. Options: role, org, scope. Defaults to role", + ), + apidocs.query_parameter( + "order", str, description="The order to sort by. Options: asc, desc. Defaults to asc" + ), + apidocs.query_parameter("page", int, description="Page number for pagination"), + apidocs.query_parameter("page_size", int, description="Number of items per page"), + ], + responses={ + status.HTTP_200_OK: TeamMemberAssignmentSerializer(many=True), + status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + status.HTTP_403_FORBIDDEN: "The user does not have the required permissions", + }, + ) + @authz_permissions( + [ + permissions.VIEW_LIBRARY_TEAM.identifier, + permissions.COURSES_VIEW_COURSE_TEAM.identifier, + ] + ) + def get(self, request: HttpRequest, username: str) -> Response: + """Retrieve all user role assignments.""" + serializer = ListTeamMemberAssignmentsQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + query_params = serializer.validated_data + + user_role_assignments: list[api.RoleAssignmentData | api.SuperAdminAssignmentData] = [] + + # Retrieve superadmin assignments (django staff or superuser users), as they always have access to everything + user_role_assignments += get_superadmin_assignments(user_external_keys=[username]) + + user_role_assignments += get_visible_user_role_assignments_filtered_by_current_user( + user_external_key=username, + orgs=query_params.get("orgs"), + roles=query_params.get("roles"), + allowed_for_user_external_key=request.user.username, + ) + + assignments = TeamMemberAssignmentSerializer(user_role_assignments, many=True).data + for backend in self.filter_backends: + assignments = backend().filter_queryset(request, assignments, self) + + # Paginate + paginator = self.pagination_class() + paginated_response_data = paginator.paginate_queryset(assignments, request) + return paginator.get_paginated_response(paginated_response_data) + + +@view_auth_classes() +class AssignmentsAPIView(APIView): + """ + API view for listing all user role assignments + This API is used on the main team members view on the Admin Console. + + **Endpoints** + + - GET: Retrieve all user role assignments + + **Query Parameters** + + - orgs (Optional): Comma-separated list of orgs to filter assignments by + - roles (Optional): Comma-separated list of roles to filter assignments by + - scopes (Optional): Comma-separated list of scopes to filter assignments by + - search (Optional): Search term to filter assignments by full_name, username, or email + - sort_by (Optional): Field to sort by. Options: role, org, scope, full_name, username, email. Defaults to full_name + - order (Optional): Sort order, 'asc' or 'desc'. Defaults to asc + - page (Optional): Page number for pagination + - page_size (Optional): Number of items per page + + **Response Format** + + Returns a paginated list of user assignment objects, each containing: + + - is_superadmin: whether this entry denotes a superadmin + - role: The role + - org: The org over which this role is applied + - scope: The scope over which this role is applied + - permission_count: The number of permissions that apply to this role + - full_name: The full name of the user in this assignment + - username: The username of the user in this assignment + - email: The email of the user in this assignment + + **Authentication and Permissions** + + - Requires authenticated user. + - Results are filtered according to calling user's "view scope team members" permissions. + + **Example Request** + + GET /api/authz/v1/assignments/?order=desc&sort_by=role&page=1&page_size=2&search=cont + + **Example Response**:: + + { + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "is_superadmin": false, + "role": "course_staff", + "org": "OpenedX", + "scope": "course-v1:OpenedX+DemoX+DemoCourse", + "permission_count": 27, + "full_name": "", + "username": "contributor", + "email": "contributor@example.com" + }, + ] + } + """ + + pagination_class = AuthZAPIViewPagination + filter_backends = [UserAssignmentsSearchFilter, UserAssignmentsOrderingFilter] + permission_classes = [AnyScopePermission] + + @apidocs.schema( + parameters=[ + apidocs.query_parameter("orgs", str, description="The orgs to query assignments for"), + apidocs.query_parameter("roles", str, description="The roles to query assignments for"), + apidocs.query_parameter("scopes", str, description="The scopes to query assignments for"), + apidocs.query_parameter( + "search", str, description="The search query to filter assignments by full_name, username, or email" + ), + apidocs.query_parameter( + "sort_by", + str, + description="The field to sort by. Options: role, org, scope, full_name, username, email", + ), + apidocs.query_parameter("order", str, description="The order to sort by"), + apidocs.query_parameter("page", int, description="Page number for pagination"), + apidocs.query_parameter("page_size", int, description="Number of items per page"), + ], + responses={ + status.HTTP_200_OK: TeamMemberUserAssignmentSerializer(many=True), + status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", + status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", + status.HTTP_403_FORBIDDEN: "The user does not have the required permissions", + }, + ) + @authz_permissions( + [ + permissions.VIEW_LIBRARY_TEAM.identifier, + permissions.COURSES_VIEW_COURSE_TEAM.identifier, + ] + ) + def get(self, request: HttpRequest) -> Response: + """Retrieve all user role assignments.""" + serializer = ListAssignmentsQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + query_params = serializer.validated_data + + user_role_assignments: list[UserAssignmentData] = [] + + users_with_assignments = get_visible_role_assignments_for_user( + orgs=query_params.get("orgs"), + scopes=query_params.get("scopes"), + roles=query_params.get("roles"), + allowed_for_user_external_key=request.user.username, + ) + + # Unpack list of UserAssignments to a list of UserAssignmentData + for uwa in users_with_assignments: + user_role_assignments += [ + UserAssignmentData( + user=uwa.user, subject=assignment.subject, roles=assignment.roles, scope=assignment.scope + ) + for assignment in uwa.assignments + ] + + assignments = TeamMemberUserAssignmentSerializer(user_role_assignments, many=True).data + for backend in self.filter_backends: + assignments = backend().filter_queryset(request, assignments, self) + + # Paginate + paginator = self.pagination_class() + paginated_response_data = paginator.paginate_queryset(assignments, request) + return paginator.get_paginated_response(paginated_response_data) diff --git a/openedx_authz/rest_api/v1/course_authoring/__init__.py b/openedx_authz/rest_api/v1/course_authoring/__init__.py new file mode 100644 index 00000000..eec0bb48 --- /dev/null +++ b/openedx_authz/rest_api/v1/course_authoring/__init__.py @@ -0,0 +1,7 @@ +"""REST API views exposing Course Authoring data. + +This package holds the one documented exception to openedx-authz's domain +boundary. This is decision item 3 in ADR 0017 (rest-api-package-layout-convention), +a subpackage named after the domain that actually owns this data. See ADR 0015 +for why the exception exists and ADR 0016 for the domain-ownership determination. +""" diff --git a/openedx_authz/rest_api/v1/course_authoring/views.py b/openedx_authz/rest_api/v1/course_authoring/views.py new file mode 100644 index 00000000..3cc0848d --- /dev/null +++ b/openedx_authz/rest_api/v1/course_authoring/views.py @@ -0,0 +1,51 @@ +""" +REST API view exposing Course Authoring data. + +This is the one endpoint in openedx-authz whose data is not Authorization's own, +kept as a bounded, documented exception. See +``docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst`` +and ``docs/decisions/0016-rest-api-domain-ownership-boundary.rst``. +""" + +import logging + +from django.http import HttpRequest +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from openedx_authz.rest_api.decorators import view_auth_classes +from openedx_authz.utils import get_waffle_flag_states + +logger = logging.getLogger(__name__) + + +@view_auth_classes() +class WaffleFlagStatesAPIView(APIView): + """ + Simple API view that returns the waffle flag states from utils.get_waffle_flag_states. + + **Endpoints** + + - GET: Retrieve the enablement state of the course-authoring waffle flag across different scopes. + + **Response Format** + + * 'global' (bool): True if the global waffle flag is enabled. + * 'org_overrides' (dict): Orgs with an organization-level override, as 'on' + (forces the flag on) and 'off' (forces the flag off) lists. + * 'course_overrides' (dict): Courses with a course-level override, split the same way. + + **Example Request** + + GET /api/authz/v1/waffle-flag-states/ + """ + + def get(self, request: HttpRequest) -> Response: + """Retrieve the enablement state of the course-authoring waffle flag across different scopes.""" + try: + data = get_waffle_flag_states() + return Response(data, status=status.HTTP_200_OK) + except Exception as e: # pylint: disable=broad-exception-caught + logger.exception("Error getting waffle flag states: %s", e) + return Response({"message": "error"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/openedx_authz/rest_api/v1/urls.py b/openedx_authz/rest_api/v1/urls.py index e31e5d79..956925f2 100644 --- a/openedx_authz/rest_api/v1/urls.py +++ b/openedx_authz/rest_api/v1/urls.py @@ -3,6 +3,8 @@ from django.urls import path from openedx_authz.rest_api.v1 import views +from openedx_authz.rest_api.v1.admin_console import views as admin_console_views +from openedx_authz.rest_api.v1.course_authoring import views as course_authoring_views urlpatterns = [ path( @@ -12,13 +14,19 @@ ), path("roles/", views.RoleListView.as_view(), name="role-list"), path("roles/users/", views.RoleUserAPIView.as_view(), name="role-user-list"), - path("orgs/", views.AdminConsoleOrgsAPIView.as_view(), name="orgs-list"), - path("users/", views.TeamMembersAPIView.as_view(), name="user-list"), + path("orgs/", admin_console_views.AdminConsoleOrgsAPIView.as_view(), name="orgs-list"), + path("users/", admin_console_views.TeamMembersAPIView.as_view(), name="user-list"), path("users/validate/", views.UserValidationAPIView.as_view(), name="user-validation"), path( - "users//assignments/", views.TeamMemberAssignmentsAPIView.as_view(), name="user-assignment-list" + "users//assignments/", + admin_console_views.TeamMemberAssignmentsAPIView.as_view(), + name="user-assignment-list", + ), + path("assignments/", admin_console_views.AssignmentsAPIView.as_view(), name="assignment-list"), + path("scopes/", admin_console_views.ScopesAPIView.as_view(), name="scope-list"), + path( + "waffle-flag-states/", + course_authoring_views.WaffleFlagStatesAPIView.as_view(), + name="waffle-flag-states", ), - path("assignments/", views.AssignmentsAPIView.as_view(), name="assignment-list"), - path("scopes/", views.ScopesAPIView.as_view(), name="scope-list"), - path("waffle-flag-states/", views.WaffleFlagStatesAPIView.as_view(), name="waffle-flag-states"), ] diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index 5fda70a6..d3ddcfab 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -3,87 +3,49 @@ This module provides Django REST Framework views for managing authorization permissions, roles, and user assignments within Open edX platform. + +These endpoints are generic to Authorization. Any consumer can call them as-is, with +no assumptions about which application is calling. """ import logging -import operator -from functools import reduce import edx_api_doc_tools as apidocs from django.contrib.auth import get_user_model -from django.db.models import CharField, Q, QuerySet, Value -from django.db.models.functions import Cast from django.http import HttpRequest -from django.utils.decorators import method_decorator -from edx_api_doc_tools import schema_for -from organizations.models import Organization -from organizations.serializers import OrganizationSerializer -from rest_framework import filters, generics, status +from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView from openedx_authz import api -from openedx_authz.api.data import ( - ContentLibraryData, - CourseOverviewData, - OrgContentLibraryGlobData, - OrgCourseOverviewGlobData, - PlatformGlobData, - RoleAssignmentData, - SuperAdminAssignmentData, - UserAssignmentData, -) -from openedx_authz.api.users import ( - get_scopes_for_user_and_permission, - get_superadmin_assignments, - get_visible_user_role_assignments_filtered_by_current_user, -) from openedx_authz.api.utils import get_user_map from openedx_authz.constants import permissions -from openedx_authz.models.scopes import get_content_library_model, get_course_overview_model -from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus, ScopesQuerySetFields, ScopesTypeField +from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus from openedx_authz.rest_api.decorators import authz_permissions, view_auth_classes from openedx_authz.rest_api.utils import ( filter_users, get_generic_scope, sort_users, ) -from openedx_authz.rest_api.v1.filters import ( - TeamMemberAssignmentsOrderingFilter, - TeamMemberOrderingFilter, - TeamMemberSearchFilter, - UserAssignmentsOrderingFilter, - UserAssignmentsSearchFilter, -) from openedx_authz.rest_api.v1.paginators import AuthZAPIViewPagination from openedx_authz.rest_api.v1.permissions import AnyScopePermission, DynamicScopePermission from openedx_authz.rest_api.v1.serializers import ( AddUsersToRoleWithScopeSerializer, - ListAssignmentsQuerySerializer, ListRolesWithScopeResponseSerializer, ListRolesWithScopeSerializer, - ListScopesQuerySerializer, - ListTeamMemberAssignmentsQuerySerializer, - ListTeamMembersSerializer, ListUsersInRoleWithScopeSerializer, PermissionValidationResponseSerializer, PermissionValidationSerializer, RemoveUsersFromRoleWithScopeSerializer, - ScopeSerializer, - TeamMemberAssignmentSerializer, - TeamMemberSerializer, - TeamMemberUserAssignmentSerializer, UserRoleAssignmentSerializer, UserValidationAPIViewResponseSerializer, UserValidationAPIViewSerializer, ) -from openedx_authz.utils import get_user_by_username_or_email, get_waffle_flag_states +from openedx_authz.utils import get_user_by_username_or_email logger = logging.getLogger(__name__) User = get_user_model() -ContentLibrary = get_content_library_model() -CourseOverview = get_course_overview_model() @view_auth_classes() @@ -519,527 +481,6 @@ def get(self, request: HttpRequest) -> Response: return paginator.get_paginated_response(serialized_data.data) -@view_auth_classes() -@method_decorator( - authz_permissions( - [ - permissions.VIEW_LIBRARY_TEAM.identifier, - permissions.COURSES_VIEW_COURSE_TEAM.identifier, - ] - ), - name="get", -) -@schema_for( - "get", - parameters=[ - apidocs.query_parameter("search", str, description="Filter orgs by name or short_name"), - apidocs.query_parameter("page", int, description="Page number for pagination"), - apidocs.query_parameter("page_size", int, description="Number of items per page"), - ], - responses={ - status.HTTP_200_OK: OrganizationSerializer(many=True), - status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", - status.HTTP_403_FORBIDDEN: "The user does not have the required permissions", - }, -) -class AdminConsoleOrgsAPIView(generics.ListAPIView): - """ - API view for listing orgs - This API is used on the filters functionality on the Admin Console. - - **Endpoints** - - - GET: Retrieve all organizations - - **Query Parameters** - - - search (Optional): Search term to filter organizations by name or short name - - page (Optional): Page number for pagination - - page_size (Optional): Number of items per page - - **Response Format** - - Returns a paginated list of organization objects, each containing: - - - id: The organization's ID - - name: The organization's name - - short_name: The organization's short name - - **Authentication and Permissions** - - - Requires authenticated user. - - **Example Request** - - GET /api/authz/v1/orgs/?search=edx&page=1&page_size=10 - - **Example Response**:: - - { - "count": 1, - "next": null, - "previous": null, - "results": [ - { - "id": 1, - "created": "2026-04-02T19:30:36.779095Z", - "modified": "2026-04-02T19:30:36.779095Z", - "name": "OpenedX", - "short_name": "OpenedX", - "description": "", - "logo": null, - "active": true - } - ] - } - """ - - serializer_class = OrganizationSerializer - pagination_class = AuthZAPIViewPagination - filter_backends = [filters.SearchFilter] - search_fields = ["name", "short_name"] - permission_classes = [AnyScopePermission] - - def get_queryset(self) -> QuerySet: - """Return active organizations ordered by name.""" - return Organization.objects.filter(active=True).order_by("name") - - -@view_auth_classes() -@method_decorator( - authz_permissions( - [ - permissions.VIEW_LIBRARY_TEAM.identifier, - permissions.COURSES_VIEW_COURSE_TEAM.identifier, - ] - ), - name="get", -) -@schema_for( - "get", - parameters=[ - apidocs.query_parameter("search", str, description="Filter scopes by display name"), - apidocs.query_parameter("org", str, description="Filter scopes by org"), - apidocs.query_parameter( - "orgs", str, description="Filter scopes by multiple orgs (comma separated list of orgs)" - ), - apidocs.query_parameter("page", int, description="Page number for pagination"), - apidocs.query_parameter("page_size", int, description="Number of items per page"), - apidocs.query_parameter( - "management_permission_only", - bool, - description=( - "If true, returns only scopes to which the calling user has manage team permission, " - "otherwise, returns any scope to which the user has view team permission." - ), - ), - apidocs.query_parameter( - "scope_type", - str, - description="Filter by scope type. Either 'course' or 'library'. Returns both if not specified.", - ), - ], - responses={ - status.HTTP_200_OK: ScopeSerializer(many=True), - status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", - status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", - status.HTTP_403_FORBIDDEN: "The user does not have the required permissions", - }, -) -class ScopesAPIView(generics.ListAPIView): - """ - API view for listing scopes - This API is used on the filters and assign roles functionality on the Admin Console. - - **Endpoints** - - - GET: Retrieve all scopes - - **Query Parameters** - - - search (Optional): Search term to filter scopes by display name - - org (Optional): Filter scopes by org - - orgs (Optional): Filter scopes by multiple orgs (comma separated list of orgs) - - page (Optional): Page number for pagination - - page_size (Optional): Number of items per page - - scope_type (Optional): Filter scopes by type. Supported values are `course` and `library`. - - management_permission_only (Optional): Filter scopes either by only the ones to which the user has "manage team" - permissions (if true), or just "view team" permissions. - - **Response Format** - - Returns a paginated list of scope objects, each containing: - - - external_key: The scope external key - - display_name: The scope's name - - org: The organization serialized object - - **Authentication and Permissions** - - - Requires authenticated user with either a content library or course view team permission. - - **Example Request** - - GET /api/authz/v1/scopes/?search=edx&page=1&page_size=10 - - **Example Response**:: - - { - "count": 1, - "next": null, - "previous": null, - "results": [ - { - "external_key": "course-v1:OpenedX+DemoX+DemoCourse", - "display_name": "Open edX Demo Course", - "org": { - "id": 1, - "created": "2026-04-02T19:30:36.779095Z", - "modified": "2026-04-02T19:30:36.779095Z", - "name": "OpenedX", - "short_name": "OpenedX", - "description": "", - "logo": null, - "active": true - } - }, - ] - } - """ - - serializer_class = ScopeSerializer - pagination_class = AuthZAPIViewPagination - permission_classes = [AnyScopePermission] - - # Priority for fields used for stable sorting (first has more priority) - ordering_priority = ( - ScopesQuerySetFields.ORG_NAME, - ScopesQuerySetFields.SCOPE_TYPE, - ScopesQuerySetFields.DISPLAY_NAME_COL, - ScopesQuerySetFields.SCOPE_ID, - ) - - def get_serializer_context(self): - context = super().get_serializer_context() - context["org_map"] = Organization.objects.filter(active=True).in_bulk(field_name="short_name") - return context - - def _get_courses_queryset( - self, - allowed_ids: set | None = None, - allowed_orgs: set | None = None, - search: str = "", - orgs: set[str] | None = None, - ) -> QuerySet: - """Return a CourseOverview queryset projected to the unified scope shape. - - If allowed_ids and/or allowed_orgs are provided, filter to matching courses. - If search is provided, filter by display_name. - If org is provided, filter by org short_name. - """ - qs = CourseOverview.objects - if allowed_ids is not None or allowed_orgs is not None: - org_filter = Q(org__in=allowed_orgs) if allowed_orgs else Q() - id_filter = Q(id__in=allowed_ids) if allowed_ids else Q() - combined_filter = org_filter | id_filter - if not combined_filter: - qs = qs.none() - else: - qs = qs.filter(combined_filter) - if orgs: - qs = qs.filter(org__in=orgs) - if search: - qs = qs.filter(display_name__icontains=search) - return qs.annotate( - scope_id=Cast("id", output_field=CharField(db_collation="utf8mb4_unicode_ci")), - display_name_col=Cast("display_name", output_field=CharField(db_collation="utf8mb4_unicode_ci")), - org_name=Cast("org", output_field=CharField(db_collation="utf8mb4_unicode_ci")), - scope_type=Value(ScopesTypeField.COURSE, output_field=CharField(db_collation="utf8mb4_unicode_ci")), - ).values( - ScopesQuerySetFields.SCOPE_ID, - ScopesQuerySetFields.DISPLAY_NAME_COL, - ScopesQuerySetFields.ORG_NAME, - ScopesQuerySetFields.SCOPE_TYPE, - ) - - def _get_libraries_queryset( - self, - allowed_pairs: set | None = None, - allowed_orgs: set | None = None, - search: str = "", - orgs: set[str] | None = None, - ) -> QuerySet: - """Return a ContentLibrary queryset projected to the unified scope shape. - - If allowed_pairs and/or allowed_orgs are provided, filter to matching libraries. - If search is provided, filter by learning_package__title. - If org is provided, filter by org short_name. - """ - qs = ContentLibrary.objects - if allowed_pairs is not None or allowed_orgs is not None: - org_filter = Q(org__short_name__in=allowed_orgs) if allowed_orgs else Q() - pair_filter = ( - reduce(operator.or_, (Q(org__short_name=org, slug=slug) for org, slug in allowed_pairs)) - if allowed_pairs - else Q() - ) - combined = org_filter | pair_filter - if not combined: - qs = qs.none() - else: - qs = qs.filter(combined) - if orgs: - qs = qs.filter(org__short_name__in=orgs) - if search: - qs = qs.filter(learning_package__title__icontains=search) - return qs.annotate( - scope_id=Cast("slug", output_field=CharField(db_collation="utf8mb4_unicode_ci")), - display_name_col=Cast("learning_package__title", output_field=CharField(db_collation="utf8mb4_unicode_ci")), - org_name=Cast("org__short_name", output_field=CharField(db_collation="utf8mb4_unicode_ci")), - scope_type=Value(ScopesTypeField.LIBRARY, output_field=CharField(db_collation="utf8mb4_unicode_ci")), - ).values( - ScopesQuerySetFields.SCOPE_ID, - ScopesQuerySetFields.DISPLAY_NAME_COL, - ScopesQuerySetFields.ORG_NAME, - ScopesQuerySetFields.SCOPE_TYPE, - ) - - @staticmethod - def _get_allowed_scope_queryset( - *, - username: str, - scope_cls: type, - org_glob_cls: type, - get_permission: callable, - queryset_builder: callable, - extract_ids: callable, - search: str = "", - orgs: set[str] | None = None, - ) -> QuerySet: - """Resolve allowed scopes from Casbin and return a filtered queryset. - - This helper encapsulates the shared pattern of: - 1. Fetching allowed scopes for a user and permission. - 2. Partitioning them into specific IDs vs org-level globs. - 3. Delegating to the appropriate queryset builder. - - Args: - username: The username to check permissions for. - scope_cls: The concrete scope data class (e.g., CourseOverviewData). - org_glob_cls: The org-level glob class (e.g., OrgCourseOverviewGlobData). - get_permission: Callable that returns the permission for a scope class. - queryset_builder: Callable that builds the filtered queryset (e.g., _get_courses_queryset). - extract_ids: Callable that extracts specific IDs from non-glob scopes. - search: Optional search term to filter by display name. - org: Optional org short_name to filter by. - - Returns: - QuerySet: The filtered queryset projected to the unified scope shape. - """ - allowed_scopes = get_scopes_for_user_and_permission(username, get_permission(scope_cls).identifier) - - has_platform_access = any(isinstance(s, PlatformGlobData) for s in allowed_scopes) - if has_platform_access: - return queryset_builder(search=search, orgs=orgs) - - specific_scopes = [s for s in allowed_scopes if not s.IS_GLOB] - allowed_ids = extract_ids(specific_scopes) - allowed_orgs = {s.org for s in allowed_scopes if isinstance(s, org_glob_cls)} - return queryset_builder(allowed_ids, allowed_orgs, search=search, orgs=orgs) - - def _build_queryset(self, courses_qs: QuerySet | None, libraries_qs: QuerySet | None) -> QuerySet: - """Union the provided querysets and sort deterministically. - - Orders by org_name first (satisfying the 'ordered by org' requirement), then by - scope_type, display_name_col, and scope_id as tiebreakers to ensure stable pagination. - """ - if courses_qs is not None and libraries_qs is not None: - return courses_qs.union(libraries_qs).order_by(*self.ordering_priority) - qs = courses_qs if courses_qs is not None else libraries_qs - return qs.order_by(*self.ordering_priority) - - def get_queryset(self) -> QuerySet: - """Return scopes ordered by org, filtered by the user's permissions.""" - user = self.request.user - - # Validate and parse query parameters. - params_serializer = ListScopesQuerySerializer(data=self.request.query_params) - params_serializer.is_valid(raise_exception=True) - scope_type = params_serializer.validated_data["scope_type"] - search = params_serializer.validated_data["search"] - org = params_serializer.validated_data.get("org", "") - orgs_param = params_serializer.validated_data.get("orgs", []) - - orgs = set() - orgs.update(orgs_param) - - if org: - orgs.add(org) - - # Staff and superusers can see all scopes, skip permission filtering. - if user.is_staff or user.is_superuser: - return self._build_queryset( - courses_qs=( - self._get_courses_queryset(search=search, orgs=orgs) - if scope_type != ScopesTypeField.LIBRARY - else None - ), - libraries_qs=( - self._get_libraries_queryset(search=search, orgs=orgs) - if scope_type != ScopesTypeField.COURSE - else None - ), - ) - - management_only = params_serializer.validated_data["management_permission_only"] - - # Determine which permission to check based on the query parameter. - def get_permission(scope_cls): - return scope_cls.get_admin_manage_permission() if management_only else scope_cls.get_admin_view_permission() - - # Resolve allowed scopes from Casbin and build filtered querysets. - courses_qs = None - if scope_type != ScopesTypeField.LIBRARY: - courses_qs = self._get_allowed_scope_queryset( - username=user.username, - scope_cls=CourseOverviewData, - org_glob_cls=OrgCourseOverviewGlobData, - get_permission=get_permission, - queryset_builder=self._get_courses_queryset, - extract_ids=lambda scopes: {s.external_key for s in scopes}, - search=search, - orgs=orgs, - ) - - libraries_qs = None - if scope_type != ScopesTypeField.COURSE: - libraries_qs = self._get_allowed_scope_queryset( - username=user.username, - scope_cls=ContentLibraryData, - org_glob_cls=OrgContentLibraryGlobData, - get_permission=get_permission, - queryset_builder=self._get_libraries_queryset, - extract_ids=lambda scopes: { - (s.external_key.split(":")[1], s.external_key.split(":")[2]) for s in scopes - }, - search=search, - orgs=orgs, - ) - - # Union the requested querysets and sort by org at the DB level. - return self._build_queryset(courses_qs, libraries_qs) - - -@view_auth_classes() -class TeamMembersAPIView(APIView): - """ - API view for listing users in relation to role assignments. - This API is used in the Team Members section in the Admin Console. - In this context, a team member is anyone with studio access. - - **Endpoints** - - - GET: Retrieve all users that have at least one role assignment - - **Query Parameters** - - - scopes (Optional): Comma-separated list of scopes to filter by (e.g., 'lib:Org1:LIB1') - - orgs (Optional): Comma-separated list of orgs to filter by (e.g., 'Org1,Org2') - - search (Optional): Search term to filter users by username, full name, or email - - sort_by (Optional): Field to sort by. Options: username, full_name, email. Defaults to username - - order (Optional): Sort order, 'asc' or 'desc'. Defaults to asc - - page (Optional): Page number for pagination - - page_size (Optional): Number of items per page - - **Response Format** - - Returns a paginated list of team member objects, each containing: - - - username: The user's username - - full_name: The user's full name - - email: The user's email address - - assignation_count: The number of role assignments the user has - - **Authentication and Permissions** - - - Requires authenticated user. - - Results are filtered according to calling user's scope-level view permissions. - - **Example Request** - - GET /api/authz/v1/users/?orgs=Org1&search=john&sort_by=username&order=asc&page=1&page_size=10 - - **Example Response**:: - - { - "count": 2, - "next": null, - "previous": null, - "results": [ - { - "username": "jane_doe", - "full_name": "Jane Doe", - "email": "jane_doe@example.com", - "assignation_count": 3 - }, - { - "username": "john_doe", - "full_name": "John Doe", - "email": "john_doe@example.com", - "assignation_count": 1 - } - ] - } - """ - - pagination_class = AuthZAPIViewPagination - filter_backends = [TeamMemberSearchFilter, TeamMemberOrderingFilter] - permission_classes = [AnyScopePermission] - - @apidocs.schema( - parameters=[ - apidocs.query_parameter("scopes", str, description="The scopes to query assignments for"), - apidocs.query_parameter("orgs", str, description="The orgs to query assignments for"), - apidocs.query_parameter("search", str, description="The search query to filter users by"), - apidocs.query_parameter("sort_by", str, description="The field to sort by"), - apidocs.query_parameter("order", str, description="The order to sort by"), - apidocs.query_parameter("page", int, description="Page number for pagination"), - apidocs.query_parameter("page_size", int, description="Number of items per page"), - ], - responses={ - status.HTTP_200_OK: TeamMemberSerializer(many=True), - status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", - status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", - status.HTTP_403_FORBIDDEN: "The user does not have the required permissions", - }, - ) - @authz_permissions( - [ - permissions.VIEW_LIBRARY_TEAM.identifier, - permissions.COURSES_VIEW_COURSE_TEAM.identifier, - ] - ) - def get(self, request: HttpRequest) -> Response: - """Retrieve all users that have at least one assignation according to the filtering fields.""" - serializer = ListTeamMembersSerializer(data=request.query_params) - serializer.is_valid(raise_exception=True) - query_params = serializer.validated_data - - users_with_assignments = api.get_visible_role_assignments_for_user( - orgs=query_params.get("orgs"), - scopes=query_params.get("scopes"), - allowed_for_user_external_key=request.user.username, - ) - - team_members = TeamMemberSerializer(users_with_assignments, many=True).data - for backend in self.filter_backends: - team_members = backend().filter_queryset(request, team_members, self) - - paginator = self.pagination_class() - paginated_response_data = paginator.paginate_queryset(team_members, request) - return paginator.get_paginated_response(paginated_response_data) - - @view_auth_classes() class UserValidationAPIView(APIView): """API view for validating that provided user identifiers correspond to existing users. @@ -1121,287 +562,3 @@ def post(self, request: HttpRequest) -> Response: } response_serializer = UserValidationAPIViewResponseSerializer(response_data) return Response(response_serializer.data, status=status.HTTP_200_OK) - - -@view_auth_classes() -class TeamMemberAssignmentsAPIView(APIView): - """ - API view for listing role assignments for a specific user. - This API is used in the Team Member detail view in the Admin Console. - - **Endpoints** - - - GET: Retrieve all role assignments for a specific user - - **URL Parameters** - - - username (Required): The username of the user to retrieve assignments for - - **Query Parameters** - - - orgs (Optional): Comma-separated list of orgs to filter assignments by (e.g., 'Org1,Org2') - - roles (Optional): Comma-separated list of roles to filter assignments by (e.g., 'library_admin,library_user') - - sort_by (Optional): Field to sort by. Options: role, org, scope. Defaults to role - - order (Optional): Sort order, 'asc' or 'desc'. Defaults to asc - - page (Optional): Page number for pagination - - page_size (Optional): Number of items per page - - **Response Format** - - Returns a paginated list of assignment objects, each containing: - - - is_superadmin: Whether this entry denotes a superadmin (staff/superuser) - - role: The role name (e.g., 'library_admin', 'django.superuser') - - org: The org over which this role is applied ('*' for superadmins) - - scope: The scope over which this role is applied ('*' for superadmins) - - permission_count: The number of permissions that apply to this role (null for superadmins) - - **Authentication and Permissions** - - - Requires authenticated user. - - Results are filtered according to calling user's scope-level view permissions. - - **Example Request** - - GET - /api/authz/v1/users/john_doe/assignments/?orgs=Org1&roles=library_admin&sort_by=role&order=asc&page=1&page_size=10 - - **Example Response**:: - - { - "count": 2, - "next": null, - "previous": null, - "results": [ - { - "is_superadmin": false, - "role": "library_admin", - "org": "Org1", - "scope": "lib:Org1:LIB1", - "permission_count": 11 - } - ] - } - """ - - pagination_class = AuthZAPIViewPagination - filter_backends = [TeamMemberAssignmentsOrderingFilter] - permission_classes = [AnyScopePermission] - - @apidocs.schema( - parameters=[ - apidocs.query_parameter("orgs", str, description="Comma-separated list of orgs to filter assignments by"), - apidocs.query_parameter("roles", str, description="Comma-separated list of roles to filter assignments by"), - apidocs.query_parameter( - "sort_by", - str, - description="The field to sort by. Options: role, org, scope. Defaults to role", - ), - apidocs.query_parameter( - "order", str, description="The order to sort by. Options: asc, desc. Defaults to asc" - ), - apidocs.query_parameter("page", int, description="Page number for pagination"), - apidocs.query_parameter("page_size", int, description="Number of items per page"), - ], - responses={ - status.HTTP_200_OK: TeamMemberAssignmentSerializer(many=True), - status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", - status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", - status.HTTP_403_FORBIDDEN: "The user does not have the required permissions", - }, - ) - @authz_permissions( - [ - permissions.VIEW_LIBRARY_TEAM.identifier, - permissions.COURSES_VIEW_COURSE_TEAM.identifier, - ] - ) - def get(self, request: HttpRequest, username: str) -> Response: - """Retrieve all user role assignments.""" - serializer = ListTeamMemberAssignmentsQuerySerializer(data=request.query_params) - serializer.is_valid(raise_exception=True) - query_params = serializer.validated_data - - user_role_assignments: list[RoleAssignmentData | SuperAdminAssignmentData] = [] - - # Retrieve superadmin assignments (django staff or superuser users), as they always have access to everything - user_role_assignments += get_superadmin_assignments(user_external_keys=[username]) - - user_role_assignments += get_visible_user_role_assignments_filtered_by_current_user( - user_external_key=username, - orgs=query_params.get("orgs"), - roles=query_params.get("roles"), - allowed_for_user_external_key=request.user.username, - ) - - assignments = TeamMemberAssignmentSerializer(user_role_assignments, many=True).data - for backend in self.filter_backends: - assignments = backend().filter_queryset(request, assignments, self) - - # Paginate - paginator = self.pagination_class() - paginated_response_data = paginator.paginate_queryset(assignments, request) - return paginator.get_paginated_response(paginated_response_data) - - -@view_auth_classes() -class AssignmentsAPIView(APIView): - """ - API view for listing all user role assignments - This API is used on the main team members view on the Admin Console. - - **Endpoints** - - - GET: Retrieve all user role assignments - - **Query Parameters** - - - orgs (Optional): Comma-separated list of orgs to filter assignments by - - roles (Optional): Comma-separated list of roles to filter assignments by - - scopes (Optional): Comma-separated list of scopes to filter assignments by - - search (Optional): Search term to filter assignments by full_name, username, or email - - sort_by (Optional): Field to sort by. Options: role, org, scope, full_name, username, email. Defaults to full_name - - order (Optional): Sort order, 'asc' or 'desc'. Defaults to asc - - page (Optional): Page number for pagination - - page_size (Optional): Number of items per page - - **Response Format** - - Returns a paginated list of user assignment objects, each containing: - - - is_superadmin: whether this entry denotes a superadmin - - role: The role - - org: The org over which this role is applied - - scope: The scope over which this role is applied - - permission_count: The number of permissions that apply to this role - - full_name: The full name of the user in this assignment - - username: The username of the user in this assignment - - email: The email of the user in this assignment - - **Authentication and Permissions** - - - Requires authenticated user. - - Results are filtered according to calling user's "view scope team members" permissions. - - **Example Request** - - GET /api/authz/v1/assignments/?order=desc&sort_by=role&page=1&page_size=2&search=cont - - **Example Response**:: - - { - "count": 2, - "next": null, - "previous": null, - "results": [ - { - "is_superadmin": false, - "role": "course_staff", - "org": "OpenedX", - "scope": "course-v1:OpenedX+DemoX+DemoCourse", - "permission_count": 27, - "full_name": "", - "username": "contributor", - "email": "contributor@example.com" - }, - ] - } - """ - - pagination_class = AuthZAPIViewPagination - filter_backends = [UserAssignmentsSearchFilter, UserAssignmentsOrderingFilter] - permission_classes = [AnyScopePermission] - - @apidocs.schema( - parameters=[ - apidocs.query_parameter("orgs", str, description="The orgs to query assignments for"), - apidocs.query_parameter("roles", str, description="The roles to query assignments for"), - apidocs.query_parameter("scopes", str, description="The scopes to query assignments for"), - apidocs.query_parameter( - "search", str, description="The search query to filter assignments by full_name, username, or email" - ), - apidocs.query_parameter( - "sort_by", - str, - description="The field to sort by. Options: role, org, scope, full_name, username, email", - ), - apidocs.query_parameter("order", str, description="The order to sort by"), - apidocs.query_parameter("page", int, description="Page number for pagination"), - apidocs.query_parameter("page_size", int, description="Number of items per page"), - ], - responses={ - status.HTTP_200_OK: TeamMemberUserAssignmentSerializer(many=True), - status.HTTP_400_BAD_REQUEST: "The request parameters are invalid", - status.HTTP_401_UNAUTHORIZED: "The user is not authenticated", - status.HTTP_403_FORBIDDEN: "The user does not have the required permissions", - }, - ) - @authz_permissions( - [ - permissions.VIEW_LIBRARY_TEAM.identifier, - permissions.COURSES_VIEW_COURSE_TEAM.identifier, - ] - ) - def get(self, request: HttpRequest) -> Response: - """Retrieve all user role assignments.""" - serializer = ListAssignmentsQuerySerializer(data=request.query_params) - serializer.is_valid(raise_exception=True) - query_params = serializer.validated_data - - user_role_assignments: list[UserAssignmentData] = [] - - users_with_assignments = api.get_visible_role_assignments_for_user( - orgs=query_params.get("orgs"), - scopes=query_params.get("scopes"), - roles=query_params.get("roles"), - allowed_for_user_external_key=request.user.username, - ) - - # Unpack list of UserAssignments to a list of UserAssignmentData - for uwa in users_with_assignments: - user_role_assignments += [ - UserAssignmentData( - user=uwa.user, subject=assignment.subject, roles=assignment.roles, scope=assignment.scope - ) - for assignment in uwa.assignments - ] - - assignments = TeamMemberUserAssignmentSerializer(user_role_assignments, many=True).data - for backend in self.filter_backends: - assignments = backend().filter_queryset(request, assignments, self) - - # Paginate - paginator = self.pagination_class() - paginated_response_data = paginator.paginate_queryset(assignments, request) - return paginator.get_paginated_response(paginated_response_data) - - -@view_auth_classes() -class WaffleFlagStatesAPIView(APIView): - """ - Simple API view that returns the waffle flag states from utils.get_waffle_flag_states. - - **Endpoints** - - - GET: Retrieve the enablement state of the course-authoring waffle flag across different scopes. - - **Response Format** - - * 'global' (bool): True if the global waffle flag is enabled. - * 'org_overrides' (dict): Orgs with an organization-level override, as 'on' - (forces the flag on) and 'off' (forces the flag off) lists. - * 'course_overrides' (dict): Courses with a course-level override, split the same way. - - **Example Request** - - GET /api/authz/v1/waffle-flag-states/ - """ - - def get(self, request: HttpRequest) -> Response: - """Retrieve the enablement state of the course-authoring waffle flag across different scopes.""" - try: - data = get_waffle_flag_states() - return Response(data, status=status.HTTP_200_OK) - except Exception as e: # pylint: disable=broad-exception-caught - logger.exception("Error getting waffle flag states: %s", e) - return Response({"message": "error"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/openedx_authz/tests/api/test_roles.py b/openedx_authz/tests/api/test_roles.py index 9fef15eb..73b95f7d 100644 --- a/openedx_authz/tests/api/test_roles.py +++ b/openedx_authz/tests/api/test_roles.py @@ -1476,7 +1476,7 @@ def test_get_field_index_and_values( scope, expected_index, expected_values, - ): # pylint: disable=too-many-positional-arguments + ): """Test that _get_field_index_and_values correctly builds field index and values. Expected result: diff --git a/openedx_authz/tests/rest_api/admin_console/__init__.py b/openedx_authz/tests/rest_api/admin_console/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_authz/tests/rest_api/admin_console/test_views.py b/openedx_authz/tests/rest_api/admin_console/test_views.py new file mode 100644 index 00000000..0ee81077 --- /dev/null +++ b/openedx_authz/tests/rest_api/admin_console/test_views.py @@ -0,0 +1,2510 @@ +""" +Unit tests for the Admin-Console-specific REST API views. + +These views (``ScopesAPIView``, ``AdminConsoleOrgsAPIView``, ``TeamMembersAPIView``, +``TeamMemberAssignmentsAPIView``, ``AssignmentsAPIView``) operate on Authorization's own +data but are shaped around the Admin Console's specific screens. See +``docs/decisions/0016-rest-api-domain-ownership-boundary.rst``. +""" + +from unittest.mock import patch + +from ddt import data, ddt, unpack +from django.contrib.auth import get_user_model +from django.urls import reverse +from organizations.models import Organization +from rest_framework import status + +from openedx_authz.api.data import OrgContentLibraryGlobData, OrgCourseOverviewGlobData, PlatformCourseOverviewGlobData +from openedx_authz.api.users import assign_role_to_user_in_scope +from openedx_authz.constants import permissions, roles +from openedx_authz.models.scopes import get_content_library_model, get_course_overview_model +from openedx_authz.rest_api.v1.admin_console.views import ScopesAPIView +from openedx_authz.tests.rest_api.test_views import ViewTestMixin +from openedx_authz.tests.stubs.models import LearningPackage + +ContentLibrary = get_content_library_model() +CourseOverview = get_course_overview_model() + +User = get_user_model() + +COURSE_SCOPE_ORG1 = "course-v1:Org1+COURSE1+2024" +COURSE_ORG1_GLOB = OrgCourseOverviewGlobData.build_external_key("Org1") +PLATFORM_COURSE_GLOB = PlatformCourseOverviewGlobData.build_external_key() + +@ddt +class TestScopesAPIView(ViewTestMixin): + """ + Test suite for ScopesAPIView. + + Setup summary (from ViewTestMixin.setUpClass): + lib:Org1:LIB1 → admin_1 (library_admin), regular_1 (library_user), regular_2 (library_user) + lib:Org2:LIB2 → admin_2 (library_user), regular_3 (library_user), regular_4 (library_user) + lib:Org3:LIB3 → admin_3 (library_admin), regular_5 (library_admin), regular_6 (library_author), + regular_7 (library_contributor), regular_8 (library_user) + + Courses and ContentLibrary objects are mocked via get_scopes_for_user_and_permission + and the queryset helper methods, since those models live in openedx-platform. + """ + + COURSE_ORG1 = COURSE_SCOPE_ORG1 + COURSE_ORG2 = "course-v1:Org2+COURSE2+2024" + LIBRARY_ORG1 = "lib:Org1:LIB1" + LIBRARY_ORG2 = "lib:Org2:LIB2" + + @classmethod + def setUpClass(cls): + """Assign course and library roles to test users.""" + super().setUpClass() + cls._assign_roles_to_users( + [ + # regular_9: can view course team on Org1 course + { + "subject_name": "regular_9", + "role_name": roles.COURSE_STAFF.external_key, + "scope_name": cls.COURSE_ORG1, + }, + # regular_10: can manage course team on Org2 course + { + "subject_name": "regular_10", + "role_name": roles.COURSE_ADMIN.external_key, + "scope_name": cls.COURSE_ORG2, + }, + ] + ) + + @classmethod + def setUpTestData(cls): + """Create Organization, CourseOverview and ContentLibrary fixtures.""" + super().setUpTestData() + + org1, _ = Organization.objects.get_or_create(name="Org1", short_name="Org1") + org2, _ = Organization.objects.get_or_create(name="Org2", short_name="Org2") + org3, _ = Organization.objects.get_or_create(name="Org3", short_name="Org3") + + CourseOverview.objects.get_or_create( + id=cls.COURSE_ORG1, defaults={"org": "Org1", "display_name": "Course Org1"} + ) + CourseOverview.objects.get_or_create( + id=cls.COURSE_ORG2, defaults={"org": "Org2", "display_name": "Course Org2"} + ) + + lp1, _ = LearningPackage.objects.get_or_create(title="Library Org1") + lp2, _ = LearningPackage.objects.get_or_create(title="Library Org2") + lp3, _ = LearningPackage.objects.get_or_create(title="Library Org3") + + ContentLibrary.objects.get_or_create( + slug="LIB1", + org=org1, + defaults={"locator": "lib:Org1:LIB1", "title": "Library Org1", "learning_package": lp1}, + ) + ContentLibrary.objects.get_or_create( + slug="LIB2", + org=org2, + defaults={"locator": "lib:Org2:LIB2", "title": "Library Org2", "learning_package": lp2}, + ) + ContentLibrary.objects.get_or_create( + slug="LIB3", + org=org3, + defaults={"locator": "lib:Org3:LIB3", "title": "Library Org3", "learning_package": lp3}, + ) + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.url = reverse("openedx_authz:scope-list") + + # Default combined result used by most tests. + self.fake_scopes = [ + { + "scope_id": self.COURSE_ORG1, + "display_name_col": "Course Org1", + "org_name": "Org1", + "scope_type": "course", + }, + {"scope_id": "LIB1", "display_name_col": "Library LIB1", "org_name": "Org1", "scope_type": "library"}, + { + "scope_id": self.COURSE_ORG2, + "display_name_col": "Course Org2", + "org_name": "Org2", + "scope_type": "course", + }, + {"scope_id": "LIB2", "display_name_col": "Library LIB2", "org_name": "Org2", "scope_type": "library"}, + ] + + # Patch _build_queryset so tests don't need real DB querysets. + self.build_qs_patcher = patch.object( + ScopesAPIView, + "_build_queryset", + return_value=self.fake_scopes, + ) + self.build_qs_patcher.start() + self.addCleanup(self.build_qs_patcher.stop) + + # ------------------------------------------------------------------ # + # Authentication # + # ------------------------------------------------------------------ # + + def test_unauthenticated_returns_401(self): + """Unauthenticated requests are rejected.""" + self.client.force_authenticate(user=None) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # ------------------------------------------------------------------ # + # Response shape # + # ------------------------------------------------------------------ # + + def test_response_shape(self): + """Each result contains external_key, display_name, and org fields.""" + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for item in response.data["results"]: + self.assertIn("external_key", item) + self.assertIn("display_name", item) + self.assertIn("org", item) + + # ------------------------------------------------------------------ # + # Sorted by org # + # ------------------------------------------------------------------ # + + def test_results_sorted_by_org(self): + """Results are sorted by org_name across courses and libraries.""" + # Stop only build_qs_patcher; libraries_qs_patcher stays active (uses stub-compatible field name). + self.build_qs_patcher.stop() + + response = self.client.get(self.url) # admin_1 is staff, sees all + + self.build_qs_patcher.start() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + org_names = [item["org"]["short_name"] if item["org"] else "" for item in response.data["results"]] + self.assertEqual(org_names, sorted(org_names)) + + # ------------------------------------------------------------------ # + # type param # + # ------------------------------------------------------------------ # + + @data( + ("course", "_get_courses_queryset", "_get_libraries_queryset"), + ("library", "_get_libraries_queryset", "_get_courses_queryset"), + ) + @unpack + def test_type_param_calls_only_expected_queryset(self, scope_type, called_method, skipped_method): + """When type=course only courses are fetched; when type=library only libraries.""" + self.build_qs_patcher.stop() + with ( + patch.object(ScopesAPIView, called_method, return_value=[]) as mock_called, + patch.object(ScopesAPIView, skipped_method) as mock_skipped, + patch.object(ScopesAPIView, "_build_queryset", return_value=[]), + ): + response = self.client.get(self.url, {"scope_type": scope_type}) + self.build_qs_patcher.start() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + mock_called.assert_called_once() + mock_skipped.assert_not_called() + + def test_type_param_invalid_returns_400(self): + """An invalid type value returns 400.""" + response = self.client.get(self.url, {"scope_type": "invalid"}) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_type_param_absent_returns_both(self): + """When type is not specified, both courses and libraries are returned.""" + self.build_qs_patcher.stop() + with ( + patch.object(ScopesAPIView, "_get_courses_queryset", return_value=[]) as mock_courses, + patch.object(ScopesAPIView, "_get_libraries_queryset", return_value=[]) as mock_libraries, + patch.object(ScopesAPIView, "_build_queryset", return_value=[]), + ): + response = self.client.get(self.url) + self.build_qs_patcher.start() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + mock_courses.assert_called_once() + mock_libraries.assert_called_once() + + # ------------------------------------------------------------------ # + # Search # + # ------------------------------------------------------------------ # + + def test_search_filters_by_display_name(self): + """search param filters results by display_name.""" + # Search is applied pre-union inside get_queryset. Use real DB rows (staff user, type=course) + # to avoid the union so the queryset remains filterable. + self.build_qs_patcher.stop() + + response_match = self.client.get(self.url, {"search": "Course Org1", "scope_type": "course"}) + response_no_match = self.client.get(self.url, {"search": "nonexistent_xyz", "scope_type": "course"}) + + self.build_qs_patcher.start() + + self.assertEqual(response_match.status_code, status.HTTP_200_OK) + self.assertEqual(response_match.data["count"], 1) + self.assertIn("Org1", response_match.data["results"][0]["display_name"]) + + self.assertEqual(response_no_match.status_code, status.HTTP_200_OK) + self.assertEqual(response_no_match.data["count"], 0) + + # ------------------------------------------------------------------ # + # Pagination # + # ------------------------------------------------------------------ # + + @data( + ({"page": 1, "page_size": 1}, 1, True), + ({"page": 2, "page_size": 1}, 1, True), + ({"page": 3, "page_size": 1}, 1, False), + ({"page": 1, "page_size": 3}, 3, False), + ) + @unpack + def test_pagination(self, query_params: dict, expected_page_count: int, has_next: bool): + """Results are paginated correctly.""" + mixed = [ + {"scope_id": self.COURSE_ORG1, "display_name_col": "Course 1", "org_name": "Org1", "scope_type": "course"}, + {"scope_id": "LIB1", "display_name_col": "Library 1", "org_name": "Org1", "scope_type": "library"}, + {"scope_id": self.COURSE_ORG2, "display_name_col": "Course 2", "org_name": "Org2", "scope_type": "course"}, + ] + self.build_qs_patcher.stop() + with patch.object(ScopesAPIView, "_build_queryset", return_value=mixed): + response = self.client.get(self.url, query_params) + self.build_qs_patcher.start() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 3) + self.assertEqual(len(response.data["results"]), expected_page_count) + if has_next: + self.assertIsNotNone(response.data["next"]) + else: + self.assertIsNone(response.data["next"]) + + # ------------------------------------------------------------------ # + # Staff / superuser bypass # + # ------------------------------------------------------------------ # + + def test_staff_sees_all_scopes_without_permission_check(self): + """Staff users bypass permission filtering and see all scopes.""" + with patch( + "openedx_authz.rest_api.v1.admin_console.views.get_scopes_for_user_and_permission" + ) as mock_get_scopes: + response = self.client.get(self.url) # admin_1 is staff + + self.assertEqual(response.status_code, status.HTTP_200_OK) + mock_get_scopes.assert_not_called() + + def test_non_staff_triggers_permission_check(self): + """Non-staff users go through get_scopes_for_user_and_permission.""" + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + + with patch( + "openedx_authz.rest_api.v1.admin_console.views.get_scopes_for_user_and_permission", + return_value=[], + ) as mock_get_scopes: + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(mock_get_scopes.call_count, 2) # once per scope type + + # ------------------------------------------------------------------ # + # Permission filtering: view # + # ------------------------------------------------------------------ # + + def test_view_permission_filters_courses_for_non_staff(self): + """Non-staff user only sees courses they have VIEW_COURSE_TEAM permission for.""" + # regular_9 has COURSE_STAFF on COURSE_ORG1 → VIEW_COURSE_TEAM granted + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + self.assertNotIn(self.COURSE_ORG2, external_keys) + + def test_view_permission_filters_libraries_for_non_staff(self): + """Non-staff user only sees libraries they have VIEW_LIBRARY_TEAM permission for.""" + # regular_1 has LIBRARY_USER on lib:Org1:LIB1 → VIEW_LIBRARY_TEAM granted + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.LIBRARY_ORG1, external_keys) + self.assertNotIn(self.LIBRARY_ORG2, external_keys) + # Verify display_name is populated from the library title, not empty. + for item in response.data["results"]: + self.assertTrue(item["display_name"]) + + def test_library_display_name_populated_in_standalone_path(self): + """display_name is non-empty for libraries when type=library bypasses the union. + + Regression test: without aliasing learning_package__title as display_name, + the standalone library queryset returns 'title' as the key and the serializer + silently produces empty strings since it only reads 'display_name'. + """ + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertGreater(response.data["count"], 0) + for item in response.data["results"]: + self.assertTrue(item["display_name"]) + + # ------------------------------------------------------------------ # + # Permission filtering: manage # + # ------------------------------------------------------------------ # + + def test_manage_permission_filters_courses_for_non_staff(self): + """management_permission_only=true filters to courses with MANAGE_COURSE_TEAM only.""" + # regular_10 has COURSE_ADMIN on COURSE_ORG2 → MANAGE_COURSE_TEAM granted + user = User.objects.get(username="regular_10") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"scope_type": "course", "management_permission_only": "true"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG2, external_keys) + self.assertNotIn(self.COURSE_ORG1, external_keys) + + def test_manage_permission_filters_libraries_for_non_staff(self): + """management_permission_only=true filters to libraries with MANAGE_LIBRARY_TEAM only.""" + # regular_5 has LIBRARY_ADMIN on lib:Org3:LIB3 → MANAGE_LIBRARY_TEAM granted + # regular_1 has LIBRARY_USER on lib:Org1:LIB1 → only VIEW, not MANAGE + user = User.objects.get(username="regular_5") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"scope_type": "library", "management_permission_only": "true"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn("lib:Org3:LIB3", external_keys) + self.assertNotIn(self.LIBRARY_ORG1, external_keys) + + def test_empty_allowed_library_pairs_returns_no_libraries(self): + """When a non-staff user has no allowed libraries, no libraries are returned. + + Regression test: an empty allowed_pairs set must not bypass the filter + and return all libraries (reduce with Q() default was a no-op). + """ + # regular_9 has no library permissions, only a course role. + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + def test_empty_allowed_course_ids_returns_no_courses(self): + """When a non-staff user has no allowed courses, no courses are returned. + + Regression test: an empty allowed_ids/allowed_orgs set must not bypass the filter + and return all courses (empty Q() | empty Q() was a no-op). + """ + # regular_1 has only library permissions, no course permissions. + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + def test_library_only_user_sees_no_courses_in_mixed_listing(self): + """A user with only library permissions sees no courses in the default mixed listing. + + Regression test: without the empty-set guard, a user with library access but no + course permissions would see all courses in the combined results. + """ + # regular_1 has only library permissions, no course permissions. + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + scope_types = {item["external_key"].split(":")[0] for item in response.data["results"]} + self.assertNotIn("course-v1", scope_types) + self.assertIn("lib", scope_types) + + def test_org_glob_scope_returns_all_org_libraries(self): + """A user with an org-level glob permission (lib:ORG:*) sees all libraries in that org.""" + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + # Simulate get_scopes_for_user_and_permission returning an org-level glob. + glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*") + with patch( + "openedx_authz.rest_api.v1.admin_console.views.get_scopes_for_user_and_permission", + return_value=[glob_scope], + ): + response = self.client.get(self.url, {"scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.LIBRARY_ORG1, external_keys) + self.assertNotIn(self.LIBRARY_ORG2, external_keys) + + def test_org_glob_scope_returns_all_org_courses(self): + """A user with an org-level glob permission (course-v1:ORG+*) sees all courses in that org.""" + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + # Simulate get_scopes_for_user_and_permission returning an org-level glob. + glob_scope = OrgCourseOverviewGlobData(external_key=COURSE_ORG1_GLOB) + with patch( + "openedx_authz.rest_api.v1.admin_console.views.get_scopes_for_user_and_permission", + return_value=[glob_scope], + ): + response = self.client.get(self.url, {"scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + self.assertNotIn(self.COURSE_ORG2, external_keys) + + def test_platform_glob_scope_returns_all_courses(self): + """A user with platform-level glob (course-v1:*) sees all courses across orgs.""" + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + platform_scope = PlatformCourseOverviewGlobData(external_key="course-v1:*") + with patch( + "openedx_authz.rest_api.v1.admin_console.views.get_scopes_for_user_and_permission", + return_value=[platform_scope], + ): + response = self.client.get(self.url, {"scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + self.assertIn(self.COURSE_ORG2, external_keys) + + def test_manage_permission_only_uses_manage_permission(self): + """management_permission_only=true calls get_admin_manage_permission, not get_admin_view_permission.""" + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + + with patch( + "openedx_authz.rest_api.v1.admin_console.views.get_scopes_for_user_and_permission", + return_value=[], + ) as mock_get_scopes: + self.client.get(self.url, {"management_permission_only": "true"}) + + called_permissions = [call.args[1] for call in mock_get_scopes.call_args_list] + self.assertIn(permissions.MANAGE_LIBRARY_TEAM.identifier, called_permissions) + self.assertIn(permissions.COURSES_MANAGE_COURSE_TEAM.identifier, called_permissions) + + def test_view_permission_only_uses_view_permission(self): + """management_permission_only=false (default) calls get_admin_view_permission.""" + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + + with patch( + "openedx_authz.rest_api.v1.admin_console.views.get_scopes_for_user_and_permission", + return_value=[], + ) as mock_get_scopes: + self.client.get(self.url) + + called_permissions = [call.args[1] for call in mock_get_scopes.call_args_list] + self.assertIn(permissions.VIEW_LIBRARY_TEAM.identifier, called_permissions) + self.assertIn(permissions.COURSES_VIEW_COURSE_TEAM.identifier, called_permissions) + + # ------------------------------------------------------------------ # + # Org filter # + # ------------------------------------------------------------------ # + + def test_org_filter_staff_courses(self): + """Staff user with org param sees only courses from that org.""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"org": "Org1", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + for item in response.data["results"]: + self.assertIn("Org1", item["external_key"]) + # Org2 course should not appear + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertNotIn(self.COURSE_ORG2, external_keys) + + def test_org_filter_staff_libraries(self): + """Staff user with org param sees only libraries from that org.""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"org": "Org2", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.LIBRARY_ORG2, external_keys) + self.assertNotIn(self.LIBRARY_ORG1, external_keys) + + def test_org_filter_staff_no_match(self): + """Staff user with org param for a non-existent org gets empty results.""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"org": "NonExistentOrg", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + def test_org_filter_non_staff_with_permission(self): + """Non-staff user with org param sees scopes only if they have permission for that org.""" + # regular_1 has LIBRARY_USER on lib:Org1:LIB1 → VIEW_LIBRARY_TEAM granted + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"org": "Org1", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.LIBRARY_ORG1, external_keys) + + def test_org_filter_non_staff_without_permission(self): + """Non-staff user with org param for an org they have no permission for gets empty results.""" + # regular_1 has no permissions on Org2 + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"org": "Org2", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + def test_org_filter_non_staff_courses(self): + """Non-staff user with org param sees only courses from that org if they have permission.""" + # regular_9 has COURSE_STAFF on COURSE_ORG1 → VIEW_COURSE_TEAM granted + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"org": "Org1", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + + def test_org_filter_non_staff_courses_no_permission(self): + """Non-staff user with org param for an org they have no course permission for gets empty results.""" + # regular_9 has no course permissions on Org2 + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"org": "Org2", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + def test_org_filter_with_glob_permission(self): + """Non-staff user with org glob permission and org filter sees only that org's scopes.""" + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*") + with patch( + "openedx_authz.rest_api.v1.admin_console.views.get_scopes_for_user_and_permission", + return_value=[glob_scope], + ): + response = self.client.get(self.url, {"org": "Org1", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.LIBRARY_ORG1, external_keys) + self.assertNotIn(self.LIBRARY_ORG2, external_keys) + + def test_org_filter_with_glob_permission_wrong_org(self): + """Non-staff user with org glob for Org1 but filtering by Org2 gets empty results.""" + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*") + with patch( + "openedx_authz.rest_api.v1.admin_console.views.get_scopes_for_user_and_permission", + return_value=[glob_scope], + ): + response = self.client.get(self.url, {"org": "Org2", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + def test_org_filter_absent_returns_all_permitted(self): + """When org param is absent, all permitted scopes are returned (existing behavior).""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Staff user sees all courses + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + self.assertIn(self.COURSE_ORG2, external_keys) + + def test_org_filter_combined_with_search(self): + """Org filter works together with search filter.""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"org": "Org1", "search": "Course", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + self.assertNotIn(self.COURSE_ORG2, external_keys) + + # ------------------------------------------------------------------ # + # Orgs filter # + # ------------------------------------------------------------------ # + + def test_orgs_filter_staff_courses(self): + """Staff user with orgs param sees only courses from that org.""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + for item in response.data["results"]: + self.assertIn("Org1", item["external_key"]) + # Org2 course should not appear + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertNotIn(self.COURSE_ORG2, external_keys) + + def test_orgs_filter_staff_libraries(self): + """Staff user with orgs param sees only libraries from that org.""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.LIBRARY_ORG2, external_keys) + self.assertNotIn(self.LIBRARY_ORG1, external_keys) + + def test_orgs_filter_staff_no_match(self): + """Staff user with orgs param for a non-existent org gets empty results.""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "NonExistentOrg", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + def test_orgs_filter_non_staff_with_permission(self): + """Non-staff user with orgs param sees scopes only if they have permission for that org.""" + # regular_1 has LIBRARY_USER on lib:Org1:LIB1 → VIEW_LIBRARY_TEAM granted + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.LIBRARY_ORG1, external_keys) + + def test_orgs_filter_non_staff_without_permission(self): + """Non-staff user with org param for an org they have no permission for gets empty results.""" + # regular_1 has no permissions on Org2 + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + def test_orgs_filter_non_staff_courses(self): + """Non-staff user with orgs param sees only courses from that org if they have permission.""" + # regular_9 has COURSE_STAFF on COURSE_ORG1 → VIEW_COURSE_TEAM granted + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + + def test_orgs_filter_non_staff_courses_no_permission(self): + """Non-staff user with orgs param for an org they have no course permission for gets empty results.""" + # regular_9 has no course permissions on Org2 + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + def test_orgs_filter_with_glob_permission(self): + """Non-staff user with orgs glob permission and org filter sees only that org's scopes.""" + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*") + with patch( + "openedx_authz.rest_api.v1.admin_console.views.get_scopes_for_user_and_permission", + return_value=[glob_scope], + ): + response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.LIBRARY_ORG1, external_keys) + self.assertNotIn(self.LIBRARY_ORG2, external_keys) + + def test_orgs_filter_with_glob_permission_wrong_org(self): + """Non-staff user with org glob for Org1 but filtering by Org2 gets empty results.""" + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*") + with patch( + "openedx_authz.rest_api.v1.admin_console.views.get_scopes_for_user_and_permission", + return_value=[glob_scope], + ): + response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "library"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + def test_orgs_filter_combined_with_search(self): + """Orgs filter works together with search filter.""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org1", "search": "Course", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + self.assertNotIn(self.COURSE_ORG2, external_keys) + + def test_orgs_filter_combined_with_org(self): + """Orgs filter works together with the singluar org filter.""" + self.build_qs_patcher.stop() + + response = self.client.get( + self.url, {"org": "Org2", "orgs": "Org1", "search": "Course", "scope_type": "course"} + ) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + self.assertIn(self.COURSE_ORG2, external_keys) + + def test_orgs_filter_with_multiple_orgs(self): + """Orgs filter with multiple orgs returns scopes from both orgs.""" + self.build_qs_patcher.stop() + + response = self.client.get(self.url, {"orgs": "Org1,Org2", "search": "Course", "scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + self.assertIn(self.COURSE_ORG2, external_keys) + + +@ddt +class TestAdminConsoleOrgsAPIView(ViewTestMixin): + """Test suite for AdminConsoleOrgsAPIView.""" + + @classmethod + def setUpClass(cls): + """Assign a course role to regular_9 for COURSES_VIEW_COURSE_TEAM permission tests.""" + super().setUpClass() + cls._assign_roles_to_users( + [ + { + "subject_name": "regular_9", + "role_name": roles.COURSE_STAFF.external_key, + "scope_name": COURSE_SCOPE_ORG1, + }, + ] + ) + + @classmethod + def setUpTestData(cls): + """Create Organization fixtures.""" + super().setUpTestData() + + Organization.objects.bulk_create( + [ + Organization(name="Alpha University", short_name="AlphaU"), + Organization(name="Beta Institute", short_name="BetaI"), + Organization(name="Gamma College", short_name="GammaC"), + ] + ) + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.url = reverse("openedx_authz:orgs-list") + + def test_get_orgs_returns_all(self): + """Test that all orgs are returned when no search param is provided. + + Expected result: + - Returns 200 OK status + - Returns all 3 orgs + """ + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 3) + self.assertEqual(len(response.data["results"]), 3) + + @data( + # Match by name + ("Alpha", 1), + ("university", 1), + # Match by short_name + ("BetaI", 1), + ("gamma", 1), + # Partial match across multiple orgs + ("a", 3), + # No match + ("nonexistent", 0), + ) + @unpack + def test_get_orgs_search(self, search_term: str, expected_count: int): + """Test filtering orgs by name or short_name via the search param. + + Expected result: + - Returns 200 OK status + - Returns only orgs matching the search term + """ + response = self.client.get(self.url, {"search": search_term}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + self.assertEqual(len(response.data["results"]), expected_count) + + @data( + ({}, 3, False), + ({"page": 1, "page_size": 2}, 2, True), + ({"page": 2, "page_size": 2}, 1, False), + ({"page": 1, "page_size": 3}, 3, False), + ) + @unpack + def test_get_orgs_pagination(self, query_params: dict, expected_count: int, has_next: bool): + """Test pagination of org results. + + Expected result: + - Returns 200 OK status + - Returns correct page size and next link + """ + response = self.client.get(self.url, query_params) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), expected_count) + if has_next: + self.assertIsNotNone(response.data["next"]) + else: + self.assertIsNone(response.data["next"]) + + def test_get_orgs_response_shape(self): + """Test that each org result contains the expected fields. + + Expected result: + - Each result has id, name, and short_name fields + """ + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + result = response.data["results"][0] + self.assertIn("id", result) + self.assertIn("name", result) + self.assertIn("short_name", result) + + def test_get_orgs_excludes_inactive(self): + """Test that inactive orgs are not returned. + + Expected result: + - Returns 200 OK status + - Inactive orgs are excluded from results + """ + Organization.objects.create(name="Inactive Org", short_name="InactiveO", active=False) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 3) + result_names = [org["name"] for org in response.data["results"]] + self.assertNotIn("Inactive Org", result_names) + + @data( + # Only VIEW_LIBRARY_TEAM (library_user role in a lib scope) + ("regular_1", status.HTTP_200_OK), + # Only COURSES_VIEW_COURSE_TEAM (course_staff role in a course scope) + ("regular_9", status.HTTP_200_OK), + # No relevant permissions + ("regular_10", status.HTTP_403_FORBIDDEN), + # Superuser + ("admin_1", status.HTTP_200_OK), + ) + @unpack + def test_get_orgs_permissions(self, username: str, expected_status: int): + """Test access control for AdminConsoleOrgsAPIView. + + Test cases: + - User with only VIEW_LIBRARY_TEAM (via library role): allowed + - User with only COURSES_VIEW_COURSE_TEAM (via course role): allowed + - User with neither permission: forbidden + - Superuser/staff: allowed + + Expected result: + - Returns appropriate status code based on user permissions + + """ + user = User.objects.get(username=username) + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, expected_status) + + def test_get_orgs_user_with_both_permissions_allowed(self): + """Test that a user with both VIEW_LIBRARY_TEAM and COURSES_VIEW_COURSE_TEAM can access the endpoint. + + Expected result: + - Returns 200 OK status + """ + # regular_1 has library_user (VIEW_LIBRARY_TEAM); assign a course role too + self._assign_roles_to_users( + [ + { + "subject_name": "regular_1", + "role_name": roles.COURSE_STAFF.external_key, + "scope_name": COURSE_SCOPE_ORG1, + }, + ] + ) + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_get_orgs_unauthenticated(self): + """Test that unauthenticated requests are rejected. + + Expected result: + - Returns 401 UNAUTHORIZED status + + """ + self.client.force_authenticate(user=None) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +@ddt +class TestTeamMembersAPIView(ViewTestMixin): + """ + Test suite for TeamMembersAPIView. + + Setup summary (from ViewTestMixin.setUpClass): + lib:Org1:LIB1 → admin_1 (library_admin), regular_1 (library_user), regular_2 (library_user) [3 users] + lib:Org2:LIB2 → admin_2 (library_user), regular_3 (library_user), regular_4 (library_user) [3 users] + lib:Org3:LIB3 → admin_3 (library_admin), regular_5 (library_admin), regular_6 (library_author), + regular_7 (library_contributor), regular_8 (library_user) [5 users] + + Total unique users with assignments: 11 + (admin_1..3 are staff/superuser; regular_1..8 are plain users) + + Visibility via filter_allowed_assignments: + - Staff/superuser: sees all 11 users (is_admin_or_superuser_check grants VIEW_LIBRARY_TEAM on lib scopes) + - regular_1 (library_user in Org1:LIB1): VIEW_LIBRARY_TEAM granted → sees Org1 members (3) + - regular_3 (library_user in Org2:LIB2): VIEW_LIBRARY_TEAM granted → sees Org2 members (3) + - regular_6 (library_author in Org3:LIB3): VIEW_LIBRARY_TEAM granted → sees Org3 members (5) + """ + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.url = reverse("openedx_authz:user-list") + + # -------------------------------------------------------------------- # + # Visibility: calling user only sees assignments it has view access to # + # -------------------------------------------------------------------- # + + @data( + # Staff/superuser sees all users across all scopes + ("admin_1", status.HTTP_200_OK, 11), + # regular_1 has LIBRARY_USER in lib:Org1:LIB1 (VIEW_LIBRARY_TEAM granted) → sees only Org1 members + ("regular_1", status.HTTP_200_OK, 3), + # regular_3 has LIBRARY_USER in lib:Org2:LIB2 (VIEW_LIBRARY_TEAM granted) → sees only Org2 members + ("regular_3", status.HTTP_200_OK, 3), + # regular_6 has LIBRARY_AUTHOR in lib:Org3:LIB3 (VIEW_LIBRARY_TEAM granted) → sees only Org3 members + ("regular_6", status.HTTP_200_OK, 5), + # regular_9 has no assignments → 403 (AnyScopePermission requires at least one relevant permission) + ("regular_9", status.HTTP_403_FORBIDDEN, None), + ) + @unpack + def test_visibility_limited_to_accessible_scopes( + self, username: str, expected_status: int, expected_count: int | None + ): + """Calling user only sees assignments for scopes it has VIEW_*_TEAM access to. + + Expected result: + - Staff/superuser sees all users across all scopes. + - Regular users only see members of scopes they have VIEW_*_TEAM permission for. + - Users with no relevant permissions get 403. + """ + user = User.objects.get(username=username) + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + self.assertEqual(response.status_code, expected_status) + if expected_count is not None: + self.assertEqual(response.data["count"], expected_count) + + def test_unauthenticated_returns_401(self): + """Unauthenticated requests are rejected. + + Expected result: + - Returns 401 UNAUTHORIZED. + """ + self.client.force_authenticate(user=None) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # -------------------------------------------------------------------- # + # Filter by scopes # + # -------------------------------------------------------------------- # + + @data( + # Single scope + ("lib:Org1:LIB1", 3), + ("lib:Org2:LIB2", 3), + ("lib:Org3:LIB3", 5), + # Multiple scopes (users are unique per scope, no overlap) + ("lib:Org1:LIB1,lib:Org2:LIB2", 6), + ("lib:Org1:LIB1,lib:Org3:LIB3", 8), + ("lib:Org1:LIB1,lib:Org2:LIB2,lib:Org3:LIB3", 11), + # Non-existent scope returns no results + ("lib:Org99:NOLIB", 0), + ) + @unpack + def test_filter_by_scopes(self, scopes: str, expected_count: int): + """Results are filtered to the requested scopes. + + Expected result: + - Only users with assignments in the given scope(s) are returned. + - Multiple scopes are OR-combined. + """ + response = self.client.get(self.url, {"scopes": scopes}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + + # ------------------------------------------------------------------ # + # Filter by orgs # + # ------------------------------------------------------------------ # + + @data( + # Single org + ("Org1", 3), + ("Org2", 3), + ("Org3", 5), + # Multiple orgs + ("Org1,Org2", 6), + ("Org1,Org3", 8), + ("Org1,Org2,Org3", 11), + # Non-existent org returns no results + ("OrgX", 0), + ) + @unpack + def test_filter_by_orgs(self, orgs: str, expected_count: int): + """Results are filtered to the requested orgs. + + Expected result: + - Only users with assignments in the given org(s) are returned. + - Multiple orgs are OR-combined. + """ + response = self.client.get(self.url, {"orgs": orgs}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + + # ------------------------------------------------------------------ # + # Search (username, full_name, email) # + # ------------------------------------------------------------------ # + + @data( + # Exact username match + ("admin_1", 1), + # Partial username match + ("admin", 3), + ("regular", 8), + # Email match + ("admin_1@example.com", 1), + ("@example.com", 11), + # No match + ("nonexistent", 0), + ) + @unpack + def test_search(self, search: str, expected_count: int): + """Search filters by username, full_name, or email (case-insensitive). + + Expected result: + - Returns only users whose username, full_name, or email contains the search term. + """ + response = self.client.get(self.url, {"search": search}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + + # ------------------------------------------------------------------ # + # Sorting # + # ------------------------------------------------------------------ # + + @data( + ("username", "asc"), + ("username", "desc"), + ("email", "asc"), + ("email", "desc"), + ("full_name", "asc"), + ("full_name", "desc"), + ) + @unpack + def test_sorting(self, sort_by: str, order: str): + """Results can be sorted by username, full_name, or email in asc/desc order. + + Expected result: + - Returns 200 OK. + - Results are ordered according to the requested field and direction. + """ + response = self.client.get(self.url, {"sort_by": sort_by, "order": order}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + values = [item[sort_by] for item in response.data["results"]] + expected = sorted(values, key=lambda v: (v or "").lower(), reverse=order == "desc") + self.assertEqual(values, expected) + + @data( + {"sort_by": "invalid"}, + {"order": "ascending"}, + {"order": "descending"}, + ) + def test_sorting_invalid_params(self, query_params: dict): + """Invalid sort_by or order values return 400. + + Expected result: + - Returns 400 BAD REQUEST. + """ + response = self.client.get(self.url, query_params) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # ------------------------------------------------------------------ # + # Pagination # + # ------------------------------------------------------------------ # + + @data( + ({"page": 1, "page_size": 5}, 5, True), + ({"page": 2, "page_size": 5}, 5, True), + ({"page": 3, "page_size": 5}, 1, False), + ({"page": 1, "page_size": 11}, 11, False), + ({"page": 1, "page_size": 6}, 6, True), + ) + @unpack + def test_pagination(self, query_params: dict, expected_page_count: int, has_next: bool): + """Results are paginated correctly. + + Expected result: + - Returns 200 OK. + - Page contains the expected number of items. + - `next` link is present only when more pages exist. + """ + response = self.client.get(self.url, query_params) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 11) + self.assertEqual(len(response.data["results"]), expected_page_count) + if has_next: + self.assertIsNotNone(response.data["next"]) + else: + self.assertIsNone(response.data["next"]) + + # ------------------------------------------------------------------ # + # Response shape # + # ------------------------------------------------------------------ # + + def test_response_shape(self): + """Each result item contains the expected fields. + + Expected result: + - Returns 200 OK. + - Each item has username, full_name, email, and assignation_count. + """ + response = self.client.get(self.url, {"scopes": "lib:Org1:LIB1"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for item in response.data["results"]: + self.assertIn("username", item) + self.assertIn("full_name", item) + self.assertIn("email", item) + self.assertIn("assignation_count", item) + self.assertEqual(item["assignation_count"], 1) + + +@ddt +class TestTeamMemberAssignmentsAPIView(ViewTestMixin): + """ + Test suite for TeamMemberAssignmentsAPIView. + + Setup summary (from ViewTestMixin.setUpClass): + lib:Org1:LIB1 → admin_1 (library_admin), regular_1 (library_user), regular_2 (library_user) + lib:Org2:LIB2 → admin_2 (library_user), regular_3 (library_user), regular_4 (library_user) + lib:Org3:LIB3 → admin_3 (library_admin), regular_5 (library_admin), regular_6 (library_author), + regular_7 (library_contributor), regular_8 (library_user) + + URL: /api/authz/v1/users//assignments/ + Response fields per item: is_superadmin, role, org, scope, permission_count + + Superadmin entry: + admin_1..3 are staff/superusers. Querying any of them always adds one + SuperAdminAssignmentData entry: role="django.superuser" (or "django.staff"), + org="*", scope="*", permission_count=None, is_superadmin=True. + This entry is always included regardless of org/role filters, since those + filters are applied only to the role assignments, not to the superadmin entry. + + Visibility via filter_allowed_assignments: + - Staff/superuser: sees all role assignments for any user, plus the superadmin + entry when the target is a superadmin. + - regular_1 (library_user in Org1:LIB1): sees only Org1:LIB1 role assignments, + plus the superadmin entry when the target is a superadmin. + - regular_9 (no assignments): rejected with 403 by AnyScopePermission + (requires at least one VIEW_LIBRARY_TEAM or COURSES_VIEW_COURSE_TEAM permission). + """ + + def _url(self, username: str) -> str: + return reverse("openedx_authz:user-assignment-list", kwargs={"username": username}) + + # -------------------------------------------------------------------- # + # Visibility: calling user only sees assignments it has view access to # + # -------------------------------------------------------------------- # + + @data( + # Staff/superuser targets get 1 superadmin entry + their role assignment(s) + ("admin_1", "admin_1", status.HTTP_200_OK, 2), # superadmin entry + library_admin in Org1 + ("admin_1", "admin_2", status.HTTP_200_OK, 2), # superadmin entry + library_user in Org2 + ("admin_1", "admin_3", status.HTTP_200_OK, 2), # superadmin entry + library_admin in Org3 + # Regular user targets get only their role assignments (no superadmin entry) + ("admin_1", "regular_5", status.HTTP_200_OK, 1), + # The superadmin entry is always included for superadmin targets, visible to all callers + ( + "regular_1", + "admin_1", + status.HTTP_200_OK, + 2, + ), # superadmin entry + library_admin in Org1 (visible via Org1 access) + # regular_1 cannot see admin_2's Org2 role assignment, but superadmin entry is still included + ("regular_1", "admin_2", status.HTTP_200_OK, 1), # superadmin entry only + # regular_9 has no assignments → 403 (AnyScopePermission requires at least one relevant permission) + ("regular_9", "admin_1", status.HTTP_403_FORBIDDEN, None), + ) + @unpack + def test_visibility_limited_to_accessible_scopes( + self, caller: str, target: str, expected_status: int, expected_count: int | None + ): + """Calling user only sees role assignments for scopes it has view access to. + + The superadmin entry is always included when the target is a superadmin, + regardless of the calling user's permissions. + + Expected result: + - Superadmin targets always include the superadmin entry. + - Role assignments are filtered by the calling user's permissions. + - Regular user targets return only their visible role assignments. + - Users with no relevant permissions get 403. + """ + self.client.force_authenticate(user=User.objects.get(username=caller)) + + response = self.client.get(self._url(target)) + + self.assertEqual(response.status_code, expected_status) + if expected_count is not None: + self.assertEqual(response.data["count"], expected_count) + + def test_unauthenticated_returns_401(self): + """Unauthenticated requests are rejected. + + Expected result: + - Returns 401 UNAUTHORIZED. + """ + self.client.force_authenticate(user=None) + + response = self.client.get(self._url("admin_1")) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_unknown_user_returns_empty(self): + """Requesting assignments for a non-existent user returns an empty list. + + Expected result: + - Returns 200 OK with count 0. + """ + response = self.client.get(self._url("nonexistent_user")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + + # ------------------------------------------------------------------ # + # Filter by orgs # + # ------------------------------------------------------------------ # + + @data( + # admin_3 has library_admin in lib:Org3:LIB3; superadmin entry is always included + ("admin_3", "Org3", 2), # superadmin entry + Org3 role assignment + ("admin_3", "Org1", 1), # superadmin entry only (no Org1 role assignment) + # regular_5 has library_admin in lib:Org3:LIB3 (no superadmin entry) + ("regular_5", "Org3", 1), + ("regular_5", "Org1", 0), + # non-existent org: superadmin entry still included for admin targets + ("admin_1", "OrgX", 1), # superadmin entry only + ) + @unpack + def test_filter_by_orgs(self, target: str, orgs: str, expected_count: int): + """Results are filtered to the requested orgs. + + Expected result: + - Only assignments in the given org(s) are returned. + - Multiple orgs are OR-combined. + """ + response = self.client.get(self._url(target), {"orgs": orgs}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + + def test_filter_by_multiple_orgs(self): + """Multiple orgs are OR-combined. + + Expected result: + - Returns assignments matching any of the given orgs. + """ + # regular_6 has library_author in lib:Org3:LIB3 + # regular_7 has library_contributor in lib:Org3:LIB3 + # Use admin_1 (staff) to see all of regular_8's assignments + # regular_8 has library_user in lib:Org3:LIB3 only + response = self.client.get(self._url("regular_8"), {"orgs": "Org1,Org3"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + + # ------------------------------------------------------------------ # + # Filter by roles # + # ------------------------------------------------------------------ # + + @data( + # role filter applies only to role assignments; superadmin entry is always included for admin targets + ("admin_1", roles.LIBRARY_ADMIN.external_key, 2), # superadmin entry + library_admin + ("admin_1", roles.LIBRARY_USER.external_key, 1), # superadmin entry only + ("regular_5", roles.LIBRARY_ADMIN.external_key, 1), + ("regular_5", roles.LIBRARY_USER.external_key, 0), + ("regular_6", roles.LIBRARY_AUTHOR.external_key, 1), + ("regular_6", roles.LIBRARY_ADMIN.external_key, 0), + ("admin_1", "non_existent_role", 1), # superadmin entry only + ) + @unpack + def test_filter_by_roles(self, target: str, role_filter: str, expected_count: int): + """Results are filtered to the requested roles. + + Expected result: + - Only assignments with the given role(s) are returned. + """ + response = self.client.get(self._url(target), {"roles": role_filter}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + + def test_filter_by_multiple_roles(self): + """Multiple roles are OR-combined for role assignments; superadmin entry always included. + + Expected result: + - Returns assignments matching any of the given roles, plus the superadmin entry. + """ + # admin_3 has library_admin in Org3:LIB3; filter for admin + author returns + # 1 role assignment + 1 superadmin entry = 2 + response = self.client.get( + self._url("admin_3"), + {"roles": f"{roles.LIBRARY_ADMIN.external_key},{roles.LIBRARY_AUTHOR.external_key}"}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 2) + + # ------------------------------------------------------------------ # + # Sorting # + # ------------------------------------------------------------------ # + + @data( + ("role", "asc"), + ("role", "desc"), + ("org", "asc"), + ("org", "desc"), + ("scope", "asc"), + ("scope", "desc"), + ) + @unpack + def test_sorting(self, sort_by: str, order: str): + """Results are sorted by role, org, or scope in asc/desc order. + + Uses admin_3, who has 2 items in the response: a superadmin entry + (role="django.superuser", org="*", scope="*") and a role assignment + (role="library_admin", org="Org3", scope="lib:Org3:LIB3"). With two + distinct values per field the sort order is non-trivial and verifiable. + + Expected result: + - Returns 200 OK. + - Results are ordered according to the requested field and direction. + """ + response = self.client.get(self._url("admin_3"), {"sort_by": sort_by, "order": order}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertGreater(len(response.data["results"]), 1) + values = [item[sort_by] for item in response.data["results"]] + expected = sorted(values, key=lambda v: (v or "").lower(), reverse=order == "desc") + self.assertEqual(values, expected) + + @data( + {"sort_by": "invalid"}, + {"sort_by": "username"}, + {"order": "ascending"}, + {"order": "descending"}, + ) + def test_sorting_invalid_params(self, query_params: dict): + """Invalid sort_by or order values return 400. + + Expected result: + - Returns 400 BAD REQUEST. + """ + response = self.client.get(self._url("admin_1"), query_params) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # ------------------------------------------------------------------ # + # Pagination # + # ------------------------------------------------------------------ # + + @data( + ({"page": 1, "page_size": 1}, 1, True), + ({"page": 2, "page_size": 1}, 1, False), + ({"page": 1, "page_size": 2}, 2, False), + ) + @unpack + def test_pagination(self, query_params: dict, expected_page_count: int, has_next: bool): + """Results are paginated correctly. + + Assigns regular_8 a second role (library_admin in Org1:LIB1) so it has + 2 assignments visible to admin_1 (staff). + + Expected result: + - Returns 200 OK. + - Page contains the expected number of items. + - `next` link is present only when more pages exist. + """ + assign_role_to_user_in_scope("regular_8", roles.LIBRARY_ADMIN.external_key, "lib:Org1:LIB1") + + response = self.client.get(self._url("regular_8"), query_params) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), expected_page_count) + if has_next: + self.assertIsNotNone(response.data["next"]) + else: + self.assertIsNone(response.data["next"]) + + def test_platform_glob_assignment_serializes_wildcard_org(self): + """User with platform glob role assignment returns org '*' in the API response. + + regular_10 is assigned course_staff on course-v1:* (all courses on the platform). + regular_9 is assigned course_admin on the same scope so they can view team + assignments for that platform-level glob. + """ + assign_role_to_user_in_scope("regular_10", roles.COURSE_STAFF.external_key, PLATFORM_COURSE_GLOB) + assign_role_to_user_in_scope("regular_9", roles.COURSE_ADMIN.external_key, PLATFORM_COURSE_GLOB) + + self.client.force_authenticate(user=User.objects.get(username="regular_9")) + response = self.client.get(self._url("regular_10")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + assignment = response.data["results"][0] + self.assertFalse(assignment["is_superadmin"]) + self.assertEqual(assignment["org"], "*") + self.assertEqual(assignment["scope"], PLATFORM_COURSE_GLOB) + self.assertEqual(assignment["role"], roles.COURSE_STAFF.external_key) + + # ------------------------------------------------------------------ # + # Response shape # + # ------------------------------------------------------------------ # + + def test_response_shape(self): + """Each result item contains the expected fields. + + admin_1 is a superuser, so the response contains two items: + - A superadmin entry with role="django.superuser", org="*", scope="*", + permission_count=None, is_superadmin=True + - A regular role assignment entry with concrete values and is_superadmin=False + + Expected result: + - Returns 200 OK. + - Each item has is_superadmin, role, org, scope, and permission_count. + """ + response = self.client.get(self._url("admin_1")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 2) + + superadmin_item = next(item for item in response.data["results"] if item["is_superadmin"]) + self.assertIn(superadmin_item["role"], ("django.superuser", "django.staff")) + self.assertEqual(superadmin_item["org"], "*") + self.assertEqual(superadmin_item["scope"], "*") + self.assertIsNone(superadmin_item["permission_count"]) + + role_item = next(item for item in response.data["results"] if not item["is_superadmin"]) + self.assertIn("role", role_item) + self.assertIn("org", role_item) + self.assertIn("scope", role_item) + self.assertIn("permission_count", role_item) + self.assertEqual(role_item["role"], roles.LIBRARY_ADMIN.external_key) + self.assertEqual(role_item["org"], "Org1") + self.assertEqual(role_item["scope"], "lib:Org1:LIB1") + self.assertGreater(role_item["permission_count"], 0) + + + + +@ddt +class TestAssignmentsAPIView(ViewTestMixin): + """ + Test suite for AssignmentsAPIView. + + Setup summary (from ViewTestMixin.setUpClass): + lib:Org1:LIB1 → admin_1 (library_admin), regular_1 (library_user), regular_2 (library_user) + lib:Org2:LIB2 → admin_2 (library_user), regular_3 (library_user), regular_4 (library_user) + lib:Org3:LIB3 → admin_3 (library_admin), regular_5 (library_admin), regular_6 (library_author), + regular_7 (library_contributor), regular_8 (library_user) + + URL: /api/authz/v1/assignments/ + Response fields per item: is_superadmin, role, org, scope, permission_count, full_name, username, email + + This endpoint returns one row per (user, assignment) pair — i.e. assignments are + "unpacked" so each row carries user info alongside the assignment fields. + Total rows when called by a staff user with no filters: + + 11 role assignments (see setup above) + = 11 rows + + Visibility via get_visible_role_assignments_for_user: + - Staff/superuser: sees all role assignments across all scopes. + - regular_1 (library_user in Org1:LIB1): sees only Org1:LIB1 assignments (3). + - regular_9 (no assignments): sees no role assignments. + """ + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.url = reverse("openedx_authz:assignment-list") + + # -------------------------------------------------------------------- # + # Visibility: calling user only sees assignments it has view access to # + # -------------------------------------------------------------------- # + + @data( + # Staff/superuser sees all: 11 role assignments + ("admin_1", status.HTTP_200_OK, 11), + # regular_1 has LIBRARY_USER in lib:Org1:LIB1 → sees 3 Org1 role assignments + ("regular_1", status.HTTP_200_OK, 3), + # regular_3 has LIBRARY_USER in lib:Org2:LIB2 → sees 3 Org2 role assignments + ("regular_3", status.HTTP_200_OK, 3), + # regular_6 has LIBRARY_AUTHOR in lib:Org3:LIB3 → sees 5 Org3 role assignments + ("regular_6", status.HTTP_200_OK, 5), + # regular_9 has no assignments → 403 (AnyScopePermission requires at least one relevant permission) + ("regular_9", status.HTTP_403_FORBIDDEN, None), + ) + @unpack + def test_visibility_limited_to_accessible_scopes( + self, username: str, expected_status: int, expected_count: int | None + ): + """Calling user only sees role assignments for scopes it has view access to. + + Users with no VIEW_LIBRARY_TEAM or COURSES_VIEW_COURSE_TEAM permission in any scope + are rejected with 403 by AnyScopePermission. + + Expected result: + - Staff/superuser sees all role assignments. + - Regular users see only assignments for their accessible scopes. + - Users with no relevant permissions get 403. + """ + user = User.objects.get(username=username) + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, expected_status) + if expected_count is not None: + self.assertEqual(response.data["count"], expected_count) + + def test_unauthenticated_returns_401(self): + """Unauthenticated requests are rejected. + + Expected result: + - Returns 401 UNAUTHORIZED. + """ + self.client.force_authenticate(user=None) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # ------------------------------------------------------------------ # + # Filter by orgs # + # ------------------------------------------------------------------ # + + @data( + ("Org1", 3), + ("Org2", 3), + ("Org3", 5), + ("OrgX", 0), + ) + @unpack + def test_filter_by_orgs(self, orgs: str, expected_count: int): + """Results are filtered to the requested orgs. + + Expected result: + - Only role assignments in the given org(s) are returned. + """ + response = self.client.get(self.url, {"orgs": orgs}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + + def test_filter_by_multiple_orgs(self): + """Multiple orgs are OR-combined. + + Expected result: + - Returns role assignments matching any of the given orgs. + """ + # Org1 has 3 role assignments, Org2 has 3 → 6 + response = self.client.get(self.url, {"orgs": "Org1,Org2"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 6) + + # ------------------------------------------------------------------ # + # Filter by roles # + # ------------------------------------------------------------------ # + + @data( + # library_admin: admin_1 (Org1), admin_3 (Org3), regular_5 (Org3) = 3 + (roles.LIBRARY_ADMIN.external_key, 3), + # library_user: admin_2 (Org2), regular_1..4 (Org1+Org2), regular_8 (Org3) = 6 + (roles.LIBRARY_USER.external_key, 6), + # library_author: regular_6 (Org3) = 1 + (roles.LIBRARY_AUTHOR.external_key, 1), + # library_contributor: regular_7 (Org3) = 1 + ("library_contributor", 1), + # Non-existent role: no matches + ("non_existent_role", 0), + ) + @unpack + def test_filter_by_roles(self, role_filter: str, expected_count: int): + """Results are filtered to the requested roles. + + Expected result: + - Only role assignments with the given role(s) are returned. + """ + response = self.client.get(self.url, {"roles": role_filter}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + + def test_filter_by_multiple_roles(self): + """Multiple roles are OR-combined. + + Expected result: + - Returns role assignments matching any of the given roles. + """ + # library_admin (3) + library_author (1) = 4 + response = self.client.get( + self.url, + {"roles": f"{roles.LIBRARY_ADMIN.external_key},{roles.LIBRARY_AUTHOR.external_key}"}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 4) + + # ------------------------------------------------------------------ # + # Filter by scopes # + # ------------------------------------------------------------------ # + + @data( + ("lib:Org1:LIB1", 3), + ("lib:Org2:LIB2", 3), + ("lib:Org3:LIB3", 5), + ("lib:Org99:NOLIB", 0), + ) + @unpack + def test_filter_by_scopes(self, scopes: str, expected_count: int): + """Results are filtered to the requested scopes. + + Expected result: + - Only role assignments in the given scope(s) are returned. + """ + response = self.client.get(self.url, {"scopes": scopes}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + + def test_filter_by_multiple_scopes(self): + """Multiple scopes are OR-combined. + + Expected result: + - Returns role assignments matching any of the given scopes. + """ + # Org1 (3) + Org2 (3) = 6 + response = self.client.get(self.url, {"scopes": "lib:Org1:LIB1,lib:Org2:LIB2"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 6) + + # ------------------------------------------------------------------ # + # Search (full_name, username, email) # + # ------------------------------------------------------------------ # + + @data( + # Exact username match — admin_1 has 1 role assignment + ("admin_1", 1), + # Partial username match — "admin" matches admin_1, admin_2, admin_3, each with 1 role assignment = 3 + ("admin", 3), + # Partial username match — "regular" matches regular_1..8 (8 role assignments) + ("regular", 8), + # Email match + ("admin_1@example.com", 1), + # Partial email match — all 11 users have @example.com + ("@example.com", 11), + # No match + ("nonexistent", 0), + ) + @unpack + def test_search(self, search: str, expected_count: int): + """Search filters by full_name, username, or email (case-insensitive). + + Expected result: + - Returns only assignments whose user's full_name, username, or email + contains the search term. + """ + response = self.client.get(self.url, {"search": search}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], expected_count) + + def test_search_case_insensitive(self): + """Search is case-insensitive. + + Expected result: + - Uppercase and lowercase search terms return the same results. + """ + response_lower = self.client.get(self.url, {"search": "admin_1"}) + response_upper = self.client.get(self.url, {"search": "ADMIN_1"}) + + self.assertEqual(response_lower.status_code, status.HTTP_200_OK) + self.assertEqual(response_upper.status_code, status.HTTP_200_OK) + self.assertEqual(response_lower.data["count"], response_upper.data["count"]) + + # ------------------------------------------------------------------ # + # Sorting # + # ------------------------------------------------------------------ # + + @data( + ("role", "asc"), + ("role", "desc"), + ("org", "asc"), + ("org", "desc"), + ("scope", "asc"), + ("scope", "desc"), + ("full_name", "asc"), + ("full_name", "desc"), + ("username", "asc"), + ("username", "desc"), + ("email", "asc"), + ("email", "desc"), + ) + @unpack + def test_sorting(self, sort_by: str, order: str): + """Results can be sorted by role, org, scope, full_name, username, or email. + + Expected result: + - Returns 200 OK. + - Results are ordered according to the requested field and direction. + """ + response = self.client.get(self.url, {"sort_by": sort_by, "order": order}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertGreater(len(response.data["results"]), 1) + values = [item[sort_by] for item in response.data["results"]] + expected = sorted(values, key=lambda v: (v or "").lower(), reverse=order == "desc") + self.assertEqual(values, expected) + + @data( + {"sort_by": "invalid"}, + {"sort_by": "permission_count"}, + {"order": "ascending"}, + {"order": "descending"}, + ) + def test_sorting_invalid_params(self, query_params: dict): + """Invalid sort_by or order values return 400. + + Expected result: + - Returns 400 BAD REQUEST. + """ + response = self.client.get(self.url, query_params) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # ------------------------------------------------------------------ # + # Pagination # + # ------------------------------------------------------------------ # + + @data( + # Total is 11 role assignments + ({"page": 1, "page_size": 5}, 5, True), + ({"page": 2, "page_size": 5}, 5, True), + ({"page": 3, "page_size": 5}, 1, False), + ({"page": 1, "page_size": 11}, 11, False), + ({"page": 1, "page_size": 7}, 7, True), + ({"page": 2, "page_size": 7}, 4, False), + ) + @unpack + def test_pagination(self, query_params: dict, expected_page_count: int, has_next: bool): + """Results are paginated correctly. + + Expected result: + - Returns 200 OK. + - Page contains the expected number of items. + - `next` link is present only when more pages exist. + """ + response = self.client.get(self.url, query_params) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 11) + self.assertEqual(len(response.data["results"]), expected_page_count) + if has_next: + self.assertIsNotNone(response.data["next"]) + else: + self.assertIsNone(response.data["next"]) + + # ------------------------------------------------------------------ # + # Response shape # + # ------------------------------------------------------------------ # + + def test_response_shape(self): + """Each result item contains the expected fields. + + Expected result: + - Returns 200 OK. + - Each item has is_superadmin, role, org, scope, permission_count, + full_name, username, and email. + """ + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_fields = { + "is_superadmin", + "role", + "org", + "scope", + "permission_count", + "full_name", + "username", + "email", + } + for item in response.data["results"]: + self.assertEqual(set(item.keys()), expected_fields) + + def test_response_shape_role_assignment_entry(self): + """Role assignment entries have the expected field values. + + Expected result: + - Role assignment entries have is_superadmin=False, concrete role/org/scope + values, a non-null permission_count, and populated user fields. + """ + # Filter to a single scope to get predictable results + response = self.client.get(self.url, {"scopes": "lib:Org1:LIB1"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + role_items = [item for item in response.data["results"] if not item["is_superadmin"]] + self.assertGreater(len(role_items), 0) + for item in role_items: + self.assertFalse(item["is_superadmin"]) + self.assertIn("role", item) + self.assertEqual(item["org"], "Org1") + self.assertEqual(item["scope"], "lib:Org1:LIB1") + self.assertIsNotNone(item["permission_count"]) + self.assertGreater(item["permission_count"], 0) + self.assertTrue(item["username"]) + self.assertTrue(item["email"]) + + # ------------------------------------------------------------------ # + # Superadmin entries # + # ------------------------------------------------------------------ # + + def test_no_superadmin_entries_in_response(self): + """The list endpoint never returns superadmin entries. + + staff/superuser entries are not backed by Casbin policies and do not + pass through the standard filter logic. They are excluded from this + endpoint entirely. + + Expected result: + - All items in the response have is_superadmin=False. + """ + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for item in response.data["results"]: + self.assertFalse(item["is_superadmin"]) + + def test_no_superadmin_entries_when_filtering_by_org(self): + """No superadmin entries appear even when an org filter is active. + + Expected result: + - No items with is_superadmin=True in the response. + """ + response = self.client.get(self.url, {"orgs": "NonExistentOrg"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + superadmin_items = [item for item in response.data["results"] if item["is_superadmin"]] + self.assertEqual(len(superadmin_items), 0) + + def test_no_superadmin_entries_when_filtering_by_role(self): + """No superadmin entries appear even when a role filter is active. + + Expected result: + - No items with is_superadmin=True in the response. + """ + response = self.client.get(self.url, {"roles": roles.LIBRARY_ADMIN.external_key}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + superadmin_items = [item for item in response.data["results"] if item["is_superadmin"]] + self.assertEqual(len(superadmin_items), 0) + + def test_no_superadmin_entries_when_filtering_by_scope(self): + """No superadmin entries appear even when a scope filter is active. + + Expected result: + - No items with is_superadmin=True in the response. + """ + response = self.client.get(self.url, {"scopes": "lib:Org1:LIB1"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + superadmin_items = [item for item in response.data["results"] if item["is_superadmin"]] + self.assertEqual(len(superadmin_items), 0) + + def test_superadmin_user_search_returns_only_role_assignments(self): + """Searching for a superadmin user returns only their role assignments. + + admin_1 is staff/superuser and has one role assignment (library_admin in Org1). + The endpoint returns role assignments only, so the search result should be 1. + + Expected result: + - Count is 1. + - The result belongs to admin_1. + """ + response = self.client.get(self.url, {"search": "admin_1"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + usernames = {item["username"] for item in response.data["results"]} + self.assertEqual(usernames, {"admin_1"}) + + def test_unprivileged_user_gets_403(self): + """A user with no relevant permissions is rejected by AnyScopePermission. + + Expected result: + - Returns 403 FORBIDDEN. + """ + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # ------------------------------------------------------------------ # + # Combined filters # + # ------------------------------------------------------------------ # + + def test_combined_org_and_role_filter(self): + """Org and role filters can be combined. + + Expected result: + - Only role assignments matching both the org and role are returned. + """ + # library_admin in Org1 = admin_1 (1 assignment) + response = self.client.get( + self.url, + {"orgs": "Org1", "roles": roles.LIBRARY_ADMIN.external_key}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + + def test_combined_scope_and_search(self): + """Scope filter and search can be combined. + + Expected result: + - Results are filtered by scope first, then search is applied. + """ + # Org1 has admin_1, regular_1, regular_2 → 3 role assignments + # Search "regular" matches regular_1, regular_2 → 2 results + response = self.client.get( + self.url, + {"scopes": "lib:Org1:LIB1", "search": "regular"}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 2) + + # ------------------------------------------------------------------ # + # Active user filtering # + # ------------------------------------------------------------------ # + + def test_inactive_users_excluded_from_results(self): + """Role assignments for inactive users are not included in results. + + Deactivating a user (is_active=False) should remove their role assignments + from the response, even though the assignments still exist in the database. + + Expected result: + - Returns 200 OK. + - The inactive user's assignments do not appear in the results. + - The total count decreases by the number of assignments the inactive user had. + """ + # Baseline: admin_1 (staff) sees all 11 role assignments + baseline_response = self.client.get(self.url) + self.assertEqual(baseline_response.status_code, status.HTTP_200_OK) + baseline_count = baseline_response.data["count"] + + # Deactivate regular_1, who has 1 role assignment in lib:Org1:LIB1 + inactive_user = User.objects.get(username="regular_1") + inactive_user.is_active = False + inactive_user.save() + try: + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + # regular_1 had 1 role assignment → count should drop by 1 + self.assertEqual(response.data["count"], baseline_count - 1) + # Confirm regular_1 is not in the results + usernames = {item["username"] for item in response.data["results"]} + self.assertNotIn("regular_1", usernames) + finally: + inactive_user.is_active = True + inactive_user.save() + + +@ddt +class TestAssignmentsAPIViewPermissions(ViewTestMixin): + """ + Test suite for AssignmentsAPIView calling-user permission scenarios. + + This class extends the base ViewTestMixin setup with course-scope assignments + to test cross-scope visibility rules. + + Base setup (from ViewTestMixin.setUpClass) — library scopes only: + lib:Org1:LIB1 → admin_1 (library_admin), regular_1 (library_user), regular_2 (library_user) + lib:Org2:LIB2 → admin_2 (library_user), regular_3 (library_user), regular_4 (library_user) + lib:Org3:LIB3 → admin_3 (library_admin), regular_5 (library_admin), regular_6 (library_author), + regular_7 (library_contributor), regular_8 (library_user) + + Additional course-scope assignments (added in this class): + course-v1:Org1+COURSE1+2024 → regular_9 (course_staff), regular_10 (course_auditor) + + Permission model: + - Library scopes require VIEW_LIBRARY_TEAM to be visible. + - Course scopes require COURSES_VIEW_COURSE_TEAM to be visible. + - Superadmins (staff/superuser) bypass all permission checks and see everything. + - Superadmin entries (from get_superadmin_assignments) are always included for all callers. + + Total role assignments after setup: + 11 library assignments + 2 course assignments = 13 role assignments + + 3 superadmin entries (admin_1, admin_2, admin_3) + = 16 total rows for a superadmin caller with no filters. + """ + + @classmethod + def setUpClass(cls): + """Add course-scope assignments on top of the base library assignments.""" + super().setUpClass() + cls._assign_roles_to_users( + [ + { + "subject_name": "regular_9", + "role_name": roles.COURSE_STAFF.external_key, + "scope_name": COURSE_SCOPE_ORG1, + }, + { + "subject_name": "regular_10", + "role_name": roles.COURSE_AUDITOR.external_key, + "scope_name": COURSE_SCOPE_ORG1, + }, + ] + ) + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.url = reverse("openedx_authz:assignment-list") + + def test_superadmin_sees_all_assignments(self): + """A superadmin caller sees all role assignments across all scope types. + + admin_1 is staff/superuser and bypasses all permission checks. The list + endpoint does not include superadmin entries, only role assignments. + + Expected result: + - Returns 200 OK. + - Sees all 13 role assignments (no superadmin entries). + """ + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 13) + + def test_superadmin_sees_library_and_course_assignments(self): + """A superadmin caller sees both library and course scope assignments. + + Expected result: + - Response includes assignments with both lib: and course-v1: scope prefixes. + """ + response = self.client.get(self.url, {"page_size": 100}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + scopes = {item["scope"] for item in non_superadmin_items} + lib_scopes = {s for s in scopes if s.startswith("lib:")} + course_scopes = {s for s in scopes if s.startswith("course-v1:")} + self.assertGreater(len(lib_scopes), 0) + self.assertGreater(len(course_scopes), 0) + + # ------------------------------------------------------------------ # + # No permissions at all # + # ------------------------------------------------------------------ # + + def test_user_without_any_permissions_gets_403(self): + """A user with no role assignments at all is rejected by AnyScopePermission. + + AnyScopePermission requires the user to have at least one of + VIEW_LIBRARY_TEAM or COURSES_VIEW_COURSE_TEAM in any scope. + A user with no assignments has neither, so they get 403. + + Expected result: + - Returns 403 FORBIDDEN. + """ + no_perms_user, _ = User.objects.get_or_create( + username="no_perms_user", + defaults={"email": "no_perms@example.com"}, + ) + self.client.force_authenticate(user=no_perms_user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # ------------------------------------------------------------------ # + # Scoped permissions: courses only # + # ------------------------------------------------------------------ # + + def test_user_with_course_scope_permission_sees_course_assignments(self): + """A user with COURSES_VIEW_COURSE_TEAM on a specific course sees those assignments. + + regular_9 has course_staff in course-v1:Org1+COURSE1+2024. + course_staff includes COURSES_VIEW_COURSE_TEAM. + + Expected result: + - Sees the 2 course assignments in course-v1:Org1+COURSE1+2024 (no superadmin entries). + - Does NOT see any library assignments (no VIEW_LIBRARY_TEAM). + """ + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 2) + + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + # All non-superadmin items should be course assignments + for item in non_superadmin_items: + self.assertTrue(item["scope"].startswith("course-v1:"), f"Expected course scope, got {item['scope']}") + + def test_user_with_course_scope_permission_does_not_see_library_assignments(self): + """A user with only course permissions cannot see library assignments. + + regular_9 has course_staff in course-v1:Org1+COURSE1+2024 but no library roles. + + Expected result: + - No library-scope assignments appear in the results. + """ + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + lib_items = [item for item in non_superadmin_items if item["scope"].startswith("lib:")] + self.assertEqual(len(lib_items), 0) + + # ------------------------------------------------------------------ # + # Scoped permissions: libraries only # + # ------------------------------------------------------------------ # + + def test_user_with_library_scope_permission_sees_library_assignments(self): + """A user with VIEW_LIBRARY_TEAM on a specific library sees those assignments. + + regular_1 has library_user in lib:Org1:LIB1. + library_user includes VIEW_LIBRARY_TEAM. + + Expected result: + - Sees the 3 library assignments in lib:Org1:LIB1 (no superadmin entries). + - Does NOT see any course assignments (no COURSES_VIEW_COURSE_TEAM). + """ + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 3) + + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + # All non-superadmin items should be library assignments + for item in non_superadmin_items: + self.assertTrue(item["scope"].startswith("lib:"), f"Expected library scope, got {item['scope']}") + + def test_user_with_library_scope_permission_does_not_see_course_assignments(self): + """A user with only library permissions cannot see course assignments. + + regular_1 has library_user in lib:Org1:LIB1 but no course roles. + + Expected result: + - No course-scope assignments appear in the results. + """ + user = User.objects.get(username="regular_1") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + course_items = [item for item in non_superadmin_items if item["scope"].startswith("course-v1:")] + self.assertEqual(len(course_items), 0) + + # ------------------------------------------------------------------ # + # Org-level permissions: courses # + # ------------------------------------------------------------------ # + + def test_user_with_org_course_permission_sees_org_course_assignments(self): + """A user with course_staff at org level sees all course assignments in that org. + + Assign regular_10 course_staff at org-level glob course-v1:Org1+* so they + can see all course assignments in Org1. + + Expected result: + - Sees course assignments in Org1. + - Does NOT see library assignments. + """ + self._assign_roles_to_users( + [ + { + "subject_name": "regular_10", + "role_name": roles.COURSE_STAFF.external_key, + "scope_name": COURSE_ORG1_GLOB, + }, + ] + ) + user = User.objects.get(username="regular_10") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + # All non-superadmin items should be course assignments + for item in non_superadmin_items: + self.assertTrue(item["scope"].startswith("course-v1:"), f"Expected course scope, got {item['scope']}") + # Should not see any library assignments + lib_items = [item for item in non_superadmin_items if item["scope"].startswith("lib:")] + self.assertEqual(len(lib_items), 0) + + # ------------------------------------------------------------------ # + # Org-level permissions: libraries # + # ------------------------------------------------------------------ # + + def test_user_with_org_library_permission_sees_org_library_assignments(self): + """A user with library_user at org level sees all library assignments in that org. + + Assign regular_9 library_user at org-level glob lib:Org1:* so they + can see all library assignments in Org1, in addition to their existing + course assignments. + + Expected result: + - Sees library assignments in Org1 + course assignments + superadmin entries. + """ + self._assign_roles_to_users( + [ + { + "subject_name": "regular_9", + "role_name": roles.LIBRARY_USER.external_key, + "scope_name": "lib:Org1:*", + }, + ] + ) + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + lib_items = [item for item in non_superadmin_items if item["scope"].startswith("lib:")] + course_items = [item for item in non_superadmin_items if item["scope"].startswith("course-v1:")] + # Should see library assignments in Org1 (3 assignments) + self.assertGreater(len(lib_items), 0) + for item in lib_items: + self.assertEqual(item["org"], "Org1") + # Should also see course assignments (from their existing course_staff role) + self.assertGreater(len(course_items), 0) + + def test_user_with_org_library_permission_does_not_see_other_org_libraries(self): + """A user with org-level library permission only sees that org's library assignments. + + Assign regular_9 library_user at org-level glob lib:Org1:* — they should + NOT see Org2 or Org3 library assignments. + + Expected result: + - Library assignments are limited to Org1. + """ + self._assign_roles_to_users( + [ + { + "subject_name": "regular_9", + "role_name": roles.LIBRARY_USER.external_key, + "scope_name": "lib:Org1:*", + }, + ] + ) + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + lib_items = [item for item in non_superadmin_items if item["scope"].startswith("lib:")] + lib_orgs = {item["org"] for item in lib_items} + self.assertEqual(lib_orgs, {"Org1"}) + + # ------------------------------------------------------------------ # + # Mixed permissions: both library and course # + # ------------------------------------------------------------------ # + + def test_user_with_both_library_and_course_permissions(self): + """A user with permissions in both library and course scopes sees both. + + Assign regular_9 library_user at lib:Org1:* (in addition to their existing + course_staff at course-v1:Org1+COURSE1+2024). + + Expected result: + - Sees both library and course assignments + superadmin entries. + """ + self._assign_roles_to_users( + [ + { + "subject_name": "regular_9", + "role_name": roles.LIBRARY_USER.external_key, + "scope_name": "lib:Org1:*", + }, + ] + ) + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] + scope_types = {item["scope"].split(":")[0] for item in non_superadmin_items} + self.assertIn("lib", scope_types) + self.assertIn("course-v1", scope_types) diff --git a/openedx_authz/tests/rest_api/course_authoring/__init__.py b/openedx_authz/tests/rest_api/course_authoring/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_authz/tests/rest_api/course_authoring/test_views.py b/openedx_authz/tests/rest_api/course_authoring/test_views.py new file mode 100644 index 00000000..3ee1e918 --- /dev/null +++ b/openedx_authz/tests/rest_api/course_authoring/test_views.py @@ -0,0 +1,63 @@ +"""Unit tests for the Course Authoring REST API view.""" + +from unittest.mock import patch + +from django.urls import reverse +from rest_framework import status + +from openedx_authz.tests.rest_api.test_views import ViewTestMixin + + +class TestWaffleFlagStatesAPIView(ViewTestMixin): + """Test suite for WaffleFlagStatesAPIView.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.url = reverse("openedx_authz:waffle-flag-states") + + def test_get_returns_the_waffle_flag_states(self): + """Test GET /waffle-flag-states/ with a successful lookup. + + Expected result: + - Returns 200 OK status. + - The response body is whatever get_waffle_flag_states returns, unchanged. + """ + flag_states = { + "global": True, + "org_overrides": {"on": ["Org1"], "off": []}, + "course_overrides": {"on": [], "off": ["course-v1:Org1+COURSE1+2024"]}, + } + with patch( + "openedx_authz.rest_api.v1.course_authoring.views.get_waffle_flag_states", return_value=flag_states + ): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, flag_states) + + def test_get_handles_an_unexpected_error(self): + """Test GET /waffle-flag-states/ when get_waffle_flag_states raises. + + Expected result: + - Returns 500 INTERNAL SERVER ERROR status with a generic error message. + """ + with patch( + "openedx_authz.rest_api.v1.course_authoring.views.get_waffle_flag_states", side_effect=Exception("boom") + ): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertEqual(response.data, {"message": "error"}) + + def test_get_requires_authentication(self): + """Test GET /waffle-flag-states/ without authentication. + + Expected result: + - Returns 401 UNAUTHORIZED status. + """ + self.client.force_authenticate(user=None) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index 30bb173f..17baade7 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -11,7 +11,6 @@ from ddt import data, ddt, unpack from django.contrib.auth import get_user_model from django.urls import reverse -from organizations.models import Organization from rest_framework import status from rest_framework.test import APIClient @@ -25,15 +24,10 @@ ) from openedx_authz.api.users import assign_role_to_user_in_scope from openedx_authz.constants import permissions, roles -from openedx_authz.models.scopes import get_content_library_model, get_course_overview_model from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus from openedx_authz.rest_api.v1.permissions import AnyScopePermission, DynamicScopePermission -from openedx_authz.rest_api.v1.views import ScopesAPIView, UserValidationAPIView +from openedx_authz.rest_api.v1.views import UserValidationAPIView from openedx_authz.tests.api.test_roles import BaseRolesTestCase -from openedx_authz.tests.stubs.models import LearningPackage - -ContentLibrary = get_content_library_model() -CourseOverview = get_course_overview_model() User = get_user_model() @@ -1144,2967 +1138,493 @@ def test_put_accepts_valid_full_course_key_scope(self, _mock_exists, _mock_assig @ddt -class TestScopesAPIView(ViewTestMixin): - """ - Test suite for ScopesAPIView. - - Setup summary (from ViewTestMixin.setUpClass): - lib:Org1:LIB1 → admin_1 (library_admin), regular_1 (library_user), regular_2 (library_user) - lib:Org2:LIB2 → admin_2 (library_user), regular_3 (library_user), regular_4 (library_user) - lib:Org3:LIB3 → admin_3 (library_admin), regular_5 (library_admin), regular_6 (library_author), - regular_7 (library_contributor), regular_8 (library_user) - - Courses and ContentLibrary objects are mocked via get_scopes_for_user_and_permission - and the queryset helper methods, since those models live in openedx-platform. - """ +class TestRoleListView(ViewTestMixin): + """Test suite for RoleListView.""" - COURSE_ORG1 = COURSE_SCOPE_ORG1 - COURSE_ORG2 = "course-v1:Org2+COURSE2+2024" - LIBRARY_ORG1 = "lib:Org1:LIB1" - LIBRARY_ORG2 = "lib:Org2:LIB2" + _COURSE_ASSIGNMENTS = [ + { + "subject_name": "course_admin", + "role_name": roles.COURSE_ADMIN.external_key, + "scope_name": COURSE_SCOPE_ORG1, + }, + { + "subject_name": "course_editor", + "role_name": roles.COURSE_EDITOR.external_key, + "scope_name": COURSE_SCOPE_ORG1, + }, + { + "subject_name": "course_auditor", + "role_name": roles.COURSE_AUDITOR.external_key, + "scope_name": COURSE_SCOPE_ORG1, + }, + ] @classmethod def setUpClass(cls): - """Assign course and library roles to test users.""" super().setUpClass() - cls._assign_roles_to_users( - [ - # regular_9: can view course team on Org1 course - { - "subject_name": "regular_9", - "role_name": roles.COURSE_STAFF.external_key, - "scope_name": cls.COURSE_ORG1, - }, - # regular_10: can manage course team on Org2 course - { - "subject_name": "regular_10", - "role_name": roles.COURSE_ADMIN.external_key, - "scope_name": cls.COURSE_ORG2, - }, - ] - ) - - @classmethod - def setUpTestData(cls): - """Create Organization, CourseOverview and ContentLibrary fixtures.""" - super().setUpTestData() - - org1, _ = Organization.objects.get_or_create(name="Org1", short_name="Org1") - org2, _ = Organization.objects.get_or_create(name="Org2", short_name="Org2") - org3, _ = Organization.objects.get_or_create(name="Org3", short_name="Org3") - - CourseOverview.objects.get_or_create( - id=cls.COURSE_ORG1, defaults={"org": "Org1", "display_name": "Course Org1"} - ) - CourseOverview.objects.get_or_create( - id=cls.COURSE_ORG2, defaults={"org": "Org2", "display_name": "Course Org2"} - ) - - lp1, _ = LearningPackage.objects.get_or_create(title="Library Org1") - lp2, _ = LearningPackage.objects.get_or_create(title="Library Org2") - lp3, _ = LearningPackage.objects.get_or_create(title="Library Org3") - - ContentLibrary.objects.get_or_create( - slug="LIB1", - org=org1, - defaults={"locator": "lib:Org1:LIB1", "title": "Library Org1", "learning_package": lp1}, - ) - ContentLibrary.objects.get_or_create( - slug="LIB2", - org=org2, - defaults={"locator": "lib:Org2:LIB2", "title": "Library Org2", "learning_package": lp2}, - ) - ContentLibrary.objects.get_or_create( - slug="LIB3", - org=org3, - defaults={"locator": "lib:Org3:LIB3", "title": "Library Org3", "learning_package": lp3}, - ) + cls._assign_roles_to_users(assignments=cls._COURSE_ASSIGNMENTS) def setUp(self): """Set up test fixtures.""" super().setUp() - self.url = reverse("openedx_authz:scope-list") - - # Default combined result used by most tests. - self.fake_scopes = [ - { - "scope_id": self.COURSE_ORG1, - "display_name_col": "Course Org1", - "org_name": "Org1", - "scope_type": "course", - }, - {"scope_id": "LIB1", "display_name_col": "Library LIB1", "org_name": "Org1", "scope_type": "library"}, - { - "scope_id": self.COURSE_ORG2, - "display_name_col": "Course Org2", - "org_name": "Org2", - "scope_type": "course", - }, - {"scope_id": "LIB2", "display_name_col": "Library LIB2", "org_name": "Org2", "scope_type": "library"}, - ] - - # Patch _build_queryset so tests don't need real DB querysets. - self.build_qs_patcher = patch.object( - ScopesAPIView, - "_build_queryset", - return_value=self.fake_scopes, - ) - self.build_qs_patcher.start() - self.addCleanup(self.build_qs_patcher.stop) - - # ------------------------------------------------------------------ # - # Authentication # - # ------------------------------------------------------------------ # - - def test_unauthenticated_returns_401(self): - """Unauthenticated requests are rejected.""" - self.client.force_authenticate(user=None) - - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.client.force_authenticate(user=self.admin_user) + self.url = reverse("openedx_authz:role-list") - # ------------------------------------------------------------------ # - # Response shape # - # ------------------------------------------------------------------ # + def test_get_roles_success(self): + """Test retrieving role definitions and their permissions. - def test_response_shape(self): - """Each result contains external_key, display_name, and org fields.""" - response = self.client.get(self.url) + Expected result: + - Returns 200 OK status + - Returns correct role definitions with permissions and user counts + """ + response = self.client.get(self.url, {"scope": "lib:Org1:LIB1"}) self.assertEqual(response.status_code, status.HTTP_200_OK) - for item in response.data["results"]: - self.assertIn("external_key", item) - self.assertIn("display_name", item) - self.assertIn("org", item) - - # ------------------------------------------------------------------ # - # Sorted by org # - # ------------------------------------------------------------------ # + self.assertIn("results", response.data) + self.assertIn("count", response.data) + self.assertEqual(len(response.data["results"]), response.data["count"]) + self.assertEqual(len(response.data["results"]), 4) - def test_results_sorted_by_org(self): - """Results are sorted by org_name across courses and libraries.""" - # Stop only build_qs_patcher; libraries_qs_patcher stays active (uses stub-compatible field name). - self.build_qs_patcher.stop() + @patch.object(api, "get_role_definitions_in_scope") + def test_get_roles_empty_result(self, mock_get_roles): + """Test retrieving roles when none exist in scope. - response = self.client.get(self.url) # admin_1 is staff, sees all + Expected result: + - Returns 200 OK status + - Returns empty results list + """ + mock_get_roles.return_value = [] - self.build_qs_patcher.start() + response = self.client.get(self.url, {"scope": "lib:Org1:LIB1"}) self.assertEqual(response.status_code, status.HTTP_200_OK) - org_names = [item["org"]["short_name"] if item["org"] else "" for item in response.data["results"]] - self.assertEqual(org_names, sorted(org_names)) - - # ------------------------------------------------------------------ # - # type param # - # ------------------------------------------------------------------ # + self.assertIn("results", response.data) + self.assertIn("count", response.data) + self.assertEqual(response.data["count"], 0) + self.assertEqual(len(response.data["results"]), 0) @data( - ("course", "_get_courses_queryset", "_get_libraries_queryset"), - ("library", "_get_libraries_queryset", "_get_courses_queryset"), + {}, + {"custom_param": "custom_value"}, + {"custom_param": "a" * 256, "another_param": "custom_value"}, ) - @unpack - def test_type_param_calls_only_expected_queryset(self, scope_type, called_method, skipped_method): - """When type=course only courses are fetched; when type=library only libraries.""" - self.build_qs_patcher.stop() - with ( - patch.object(ScopesAPIView, called_method, return_value=[]) as mock_called, - patch.object(ScopesAPIView, skipped_method) as mock_skipped, - patch.object(ScopesAPIView, "_build_queryset", return_value=[]), - ): - response = self.client.get(self.url, {"scope_type": scope_type}) - self.build_qs_patcher.start() - - self.assertEqual(response.status_code, status.HTTP_200_OK) - mock_called.assert_called_once() - mock_skipped.assert_not_called() + def test_get_roles_scope_is_missing(self, query_params: dict): + """Test retrieving roles with scope is missing. - def test_type_param_invalid_returns_400(self): - """An invalid type value returns 400.""" - response = self.client.get(self.url, {"scope_type": "invalid"}) + Expected result: + - Returns 400 BAD REQUEST status + """ + response = self.client.get(self.url, query_params) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("required", [error.code for error in response.data["scope"]]) - def test_type_param_absent_returns_both(self): - """When type is not specified, both courses and libraries are returned.""" - self.build_qs_patcher.stop() - with ( - patch.object(ScopesAPIView, "_get_courses_queryset", return_value=[]) as mock_courses, - patch.object(ScopesAPIView, "_get_libraries_queryset", return_value=[]) as mock_libraries, - patch.object(ScopesAPIView, "_build_queryset", return_value=[]), - ): - response = self.client.get(self.url) - self.build_qs_patcher.start() - - self.assertEqual(response.status_code, status.HTTP_200_OK) - mock_courses.assert_called_once() - mock_libraries.assert_called_once() - - # ------------------------------------------------------------------ # - # Search # - # ------------------------------------------------------------------ # - - def test_search_filters_by_display_name(self): - """search param filters results by display_name.""" - # Search is applied pre-union inside get_queryset. Use real DB rows (staff user, type=course) - # to avoid the union so the queryset remains filterable. - self.build_qs_patcher.stop() - - response_match = self.client.get(self.url, {"search": "Course Org1", "scope_type": "course"}) - response_no_match = self.client.get(self.url, {"search": "nonexistent_xyz", "scope_type": "course"}) - - self.build_qs_patcher.start() - - self.assertEqual(response_match.status_code, status.HTTP_200_OK) - self.assertEqual(response_match.data["count"], 1) - self.assertIn("Org1", response_match.data["results"][0]["display_name"]) + @data( + ({"scope": ""}, "blank"), + ({"scope": "a" * 256}, "max_length"), + ({"scope": "invalid"}, "invalid"), + ({"scope": "*"}, "invalid"), + ) + @unpack + def test_get_roles_scope_is_invalid(self, query_params: dict, error_code: str): + """Test retrieving roles with invalid scope. - self.assertEqual(response_no_match.status_code, status.HTTP_200_OK) - self.assertEqual(response_no_match.data["count"], 0) + Expected result: + - Returns 400 BAD REQUEST status + """ + response = self.client.get(self.url, query_params) - # ------------------------------------------------------------------ # - # Pagination # - # ------------------------------------------------------------------ # + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(error_code, [error.code for error in response.data["scope"]]) @data( - ({"page": 1, "page_size": 1}, 1, True), - ({"page": 2, "page_size": 1}, 1, True), - ({"page": 3, "page_size": 1}, 1, False), - ({"page": 1, "page_size": 3}, 3, False), + ({}, 4, False), + ({"page": 1, "page_size": 2}, 2, True), + ({"page": 2, "page_size": 2}, 2, False), + ({"page": 1, "page_size": 4}, 4, False), ) @unpack - def test_pagination(self, query_params: dict, expected_page_count: int, has_next: bool): - """Results are paginated correctly.""" - mixed = [ - {"scope_id": self.COURSE_ORG1, "display_name_col": "Course 1", "org_name": "Org1", "scope_type": "course"}, - {"scope_id": "LIB1", "display_name_col": "Library 1", "org_name": "Org1", "scope_type": "library"}, - {"scope_id": self.COURSE_ORG2, "display_name_col": "Course 2", "org_name": "Org2", "scope_type": "course"}, - ] - self.build_qs_patcher.stop() - with patch.object(ScopesAPIView, "_build_queryset", return_value=mixed): - response = self.client.get(self.url, query_params) - self.build_qs_patcher.start() + def test_get_roles_pagination(self, query_params: dict, expected_count: int, has_next: bool): + """Test retrieving roles with pagination. + + Expected result: + - Returns 200 OK status + - Returns paginated results with correct page size + """ + query_params["scope"] = "lib:Org1:LIB1" + response = self.client.get(self.url, query_params) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 3) - self.assertEqual(len(response.data["results"]), expected_page_count) + self.assertIn("results", response.data) + self.assertEqual(len(response.data["results"]), expected_count) + self.assertIn("next", response.data) if has_next: self.assertIsNotNone(response.data["next"]) else: self.assertIsNone(response.data["next"]) - # ------------------------------------------------------------------ # - # Staff / superuser bypass # - # ------------------------------------------------------------------ # - - def test_staff_sees_all_scopes_without_permission_check(self): - """Staff users bypass permission filtering and see all scopes.""" - with patch("openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission") as mock_get_scopes: - response = self.client.get(self.url) # admin_1 is staff - - self.assertEqual(response.status_code, status.HTTP_200_OK) - mock_get_scopes.assert_not_called() - - def test_non_staff_triggers_permission_check(self): - """Non-staff users go through get_scopes_for_user_and_permission.""" - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - - with patch( - "openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission", - return_value=[], - ) as mock_get_scopes: - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(mock_get_scopes.call_count, 2) # once per scope type - - # ------------------------------------------------------------------ # - # Permission filtering: view # - # ------------------------------------------------------------------ # - - def test_view_permission_filters_courses_for_non_staff(self): - """Non-staff user only sees courses they have VIEW_COURSE_TEAM permission for.""" - # regular_9 has COURSE_STAFF on COURSE_ORG1 → VIEW_COURSE_TEAM granted - user = User.objects.get(username="regular_9") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.COURSE_ORG1, external_keys) - self.assertNotIn(self.COURSE_ORG2, external_keys) - - def test_view_permission_filters_libraries_for_non_staff(self): - """Non-staff user only sees libraries they have VIEW_LIBRARY_TEAM permission for.""" - # regular_1 has LIBRARY_USER on lib:Org1:LIB1 → VIEW_LIBRARY_TEAM granted - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"scope_type": "library"}) + @data( + # Unauthenticated + (None, status.HTTP_401_UNAUTHORIZED), + # Admin user + ("admin_1", status.HTTP_200_OK), + # Library Admin user + ("regular_5", status.HTTP_200_OK), + # Library Author user + ("regular_6", status.HTTP_200_OK), + # Library Contributor user + ("regular_7", status.HTTP_200_OK), + # Library User user + ("regular_8", status.HTTP_200_OK), + # Regular user without permission + ("regular_9", status.HTTP_403_FORBIDDEN), + # Non existent user + ("non_existent_user", status.HTTP_401_UNAUTHORIZED), + ) + @unpack + def test_get_roles_permissions(self, username: str, status_code: int): + """Test retrieving roles with permissions. - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.LIBRARY_ORG1, external_keys) - self.assertNotIn(self.LIBRARY_ORG2, external_keys) - # Verify display_name is populated from the library title, not empty. - for item in response.data["results"]: - self.assertTrue(item["display_name"]) - - def test_library_display_name_populated_in_standalone_path(self): - """display_name is non-empty for libraries when type=library bypasses the union. - - Regression test: without aliasing learning_package__title as display_name, - the standalone library queryset returns 'title' as the key and the serializer - silently produces empty strings since it only reads 'display_name'. + Expected result: + - Returns 401 UNAUTHORIZED status if user is not authenticated + - Returns 403 FORBIDDEN status if user does not have permission + - Returns 200 OK status if user has permission with correct roles with permissions and user counts """ - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"scope_type": "library"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertGreater(response.data["count"], 0) - for item in response.data["results"]: - self.assertTrue(item["display_name"]) - - # ------------------------------------------------------------------ # - # Permission filtering: manage # - # ------------------------------------------------------------------ # - - def test_manage_permission_filters_courses_for_non_staff(self): - """management_permission_only=true filters to courses with MANAGE_COURSE_TEAM only.""" - # regular_10 has COURSE_ADMIN on COURSE_ORG2 → MANAGE_COURSE_TEAM granted - user = User.objects.get(username="regular_10") + user = User.objects.filter(username=username).first() self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"scope_type": "course", "management_permission_only": "true"}) - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.COURSE_ORG2, external_keys) - self.assertNotIn(self.COURSE_ORG1, external_keys) - - def test_manage_permission_filters_libraries_for_non_staff(self): - """management_permission_only=true filters to libraries with MANAGE_LIBRARY_TEAM only.""" - # regular_5 has LIBRARY_ADMIN on lib:Org3:LIB3 → MANAGE_LIBRARY_TEAM granted - # regular_1 has LIBRARY_USER on lib:Org1:LIB1 → only VIEW, not MANAGE - user = User.objects.get(username="regular_5") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() + response = self.client.get(self.url, {"scope": "lib:Org3:LIB3"}) - response = self.client.get(self.url, {"scope_type": "library", "management_permission_only": "true"}) + self.assertEqual(response.status_code, status_code) + if status_code == status.HTTP_200_OK: + self.assertIn("results", response.data) + self.assertIn("count", response.data) - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn("lib:Org3:LIB3", external_keys) - self.assertNotIn(self.LIBRARY_ORG1, external_keys) + # --- Course scope equivalents --- - def test_empty_allowed_library_pairs_returns_no_libraries(self): - """When a non-staff user has no allowed libraries, no libraries are returned. + @data( + # Unauthenticated + (None, status.HTTP_401_UNAUTHORIZED), + # Django superuser always passes + ("admin_1", status.HTTP_200_OK), + # course_admin has COURSES_MANAGE_COURSE_TEAM ⊇ COURSES_VIEW_COURSE_TEAM + ("course_admin", status.HTTP_200_OK), + # course_auditor has COURSES_VIEW_COURSE_TEAM + ("course_auditor", status.HTTP_200_OK), + # Library-only user has no course permission + ("regular_9", status.HTTP_403_FORBIDDEN), + ) + @unpack + def test_get_roles_course_permissions(self, username: str, status_code: int): + """Mirror of test_get_roles_permissions for course scopes. - Regression test: an empty allowed_pairs set must not bypass the filter - and return all libraries (reduce with Q() default was a no-op). + Expected result: + - Returns appropriate status code based on course-scope permissions. """ - # regular_9 has no library permissions, only a course role. - user = User.objects.get(username="regular_9") + user = User.objects.filter(username=username).first() self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"scope_type": "library"}) - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 0) + response = self.client.get(self.url, {"scope": COURSE_SCOPE_ORG1}) - def test_empty_allowed_course_ids_returns_no_courses(self): - """When a non-staff user has no allowed courses, no courses are returned. + self.assertEqual(response.status_code, status_code) - Regression test: an empty allowed_ids/allowed_orgs set must not bypass the filter - and return all courses (empty Q() | empty Q() was a no-op). - """ - # regular_1 has only library permissions, no course permissions. - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - response = self.client.get(self.url, {"scope_type": "course"}) +@ddt +class TestUserValidationAPIView(ViewTestMixin): + """Test suite for UserValidationAPIView.""" - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 0) + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.url = reverse("openedx_authz:user-validation") - def test_library_only_user_sees_no_courses_in_mixed_listing(self): - """A user with only library permissions sees no courses in the default mixed listing. + @data( + # All users valid - usernames + (["admin_1", "regular_1"], ["admin_1", "regular_1"], []), + # All users valid - emails + (["admin_1@example.com", "regular_1@example.com"], ["admin_1@example.com", "regular_1@example.com"], []), + # Mixed usernames and emails + (["admin_1", "regular_1@example.com"], ["admin_1", "regular_1@example.com"], []), + # Single user + (["admin_1"], ["admin_1"], []), + ) + @unpack + def test_post_all_users_valid(self, input_users: list, expected_valid: list, expected_invalid: list): + """Test user validation when all users are valid. - Regression test: without the empty-set guard, a user with library access but no - course permissions would see all courses in the combined results. + Expected result: + - Returns 200 OK status + - All users are in valid_users list + - invalid_users list is empty + - Summary contains correct counts """ - # regular_1 has only library permissions, no course permissions. - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - response = self.client.get(self.url) - - self.build_qs_patcher.start() + self.client.force_authenticate(user=self.admin_user) + request_data = {"users": input_users} + response = self.client.post(self.url, data=request_data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) - scope_types = {item["external_key"].split(":")[0] for item in response.data["results"]} - self.assertNotIn("course-v1", scope_types) - self.assertIn("lib", scope_types) - - def test_org_glob_scope_returns_all_org_libraries(self): - """A user with an org-level glob permission (lib:ORG:*) sees all libraries in that org.""" - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - # Simulate get_scopes_for_user_and_permission returning an org-level glob. - glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*") - with patch( - "openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission", - return_value=[glob_scope], - ): - response = self.client.get(self.url, {"scope_type": "library"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.LIBRARY_ORG1, external_keys) - self.assertNotIn(self.LIBRARY_ORG2, external_keys) - - def test_org_glob_scope_returns_all_org_courses(self): - """A user with an org-level glob permission (course-v1:ORG+*) sees all courses in that org.""" - user = User.objects.get(username="regular_9") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - # Simulate get_scopes_for_user_and_permission returning an org-level glob. - glob_scope = OrgCourseOverviewGlobData(external_key=COURSE_ORG1_GLOB) - with patch( - "openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission", - return_value=[glob_scope], - ): - response = self.client.get(self.url, {"scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.COURSE_ORG1, external_keys) - self.assertNotIn(self.COURSE_ORG2, external_keys) - - def test_platform_glob_scope_returns_all_courses(self): - """A user with platform-level glob (course-v1:*) sees all courses across orgs.""" - user = User.objects.get(username="regular_9") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - platform_scope = PlatformCourseOverviewGlobData(external_key="course-v1:*") - with patch( - "openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission", - return_value=[platform_scope], - ): - response = self.client.get(self.url, {"scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.COURSE_ORG1, external_keys) - self.assertIn(self.COURSE_ORG2, external_keys) - - def test_manage_permission_only_uses_manage_permission(self): - """management_permission_only=true calls get_admin_manage_permission, not get_admin_view_permission.""" - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - - with patch( - "openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission", - return_value=[], - ) as mock_get_scopes: - self.client.get(self.url, {"management_permission_only": "true"}) - - called_permissions = [call.args[1] for call in mock_get_scopes.call_args_list] - self.assertIn(permissions.MANAGE_LIBRARY_TEAM.identifier, called_permissions) - self.assertIn(permissions.COURSES_MANAGE_COURSE_TEAM.identifier, called_permissions) - - def test_view_permission_only_uses_view_permission(self): - """management_permission_only=false (default) calls get_admin_view_permission.""" - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - - with patch( - "openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission", - return_value=[], - ) as mock_get_scopes: - self.client.get(self.url) - - called_permissions = [call.args[1] for call in mock_get_scopes.call_args_list] - self.assertIn(permissions.VIEW_LIBRARY_TEAM.identifier, called_permissions) - self.assertIn(permissions.COURSES_VIEW_COURSE_TEAM.identifier, called_permissions) - - # ------------------------------------------------------------------ # - # Org filter # - # ------------------------------------------------------------------ # - - def test_org_filter_staff_courses(self): - """Staff user with org param sees only courses from that org.""" - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"org": "Org1", "scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - for item in response.data["results"]: - self.assertIn("Org1", item["external_key"]) - # Org2 course should not appear - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertNotIn(self.COURSE_ORG2, external_keys) - - def test_org_filter_staff_libraries(self): - """Staff user with org param sees only libraries from that org.""" - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"org": "Org2", "scope_type": "library"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.LIBRARY_ORG2, external_keys) - self.assertNotIn(self.LIBRARY_ORG1, external_keys) - - def test_org_filter_staff_no_match(self): - """Staff user with org param for a non-existent org gets empty results.""" - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"org": "NonExistentOrg", "scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 0) - - def test_org_filter_non_staff_with_permission(self): - """Non-staff user with org param sees scopes only if they have permission for that org.""" - # regular_1 has LIBRARY_USER on lib:Org1:LIB1 → VIEW_LIBRARY_TEAM granted - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"org": "Org1", "scope_type": "library"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.LIBRARY_ORG1, external_keys) - - def test_org_filter_non_staff_without_permission(self): - """Non-staff user with org param for an org they have no permission for gets empty results.""" - # regular_1 has no permissions on Org2 - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"org": "Org2", "scope_type": "library"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 0) - - def test_org_filter_non_staff_courses(self): - """Non-staff user with org param sees only courses from that org if they have permission.""" - # regular_9 has COURSE_STAFF on COURSE_ORG1 → VIEW_COURSE_TEAM granted - user = User.objects.get(username="regular_9") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"org": "Org1", "scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.COURSE_ORG1, external_keys) - - def test_org_filter_non_staff_courses_no_permission(self): - """Non-staff user with org param for an org they have no course permission for gets empty results.""" - # regular_9 has no course permissions on Org2 - user = User.objects.get(username="regular_9") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"org": "Org2", "scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 0) - - def test_org_filter_with_glob_permission(self): - """Non-staff user with org glob permission and org filter sees only that org's scopes.""" - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*") - with patch( - "openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission", - return_value=[glob_scope], - ): - response = self.client.get(self.url, {"org": "Org1", "scope_type": "library"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.LIBRARY_ORG1, external_keys) - self.assertNotIn(self.LIBRARY_ORG2, external_keys) - - def test_org_filter_with_glob_permission_wrong_org(self): - """Non-staff user with org glob for Org1 but filtering by Org2 gets empty results.""" - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*") - with patch( - "openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission", - return_value=[glob_scope], - ): - response = self.client.get(self.url, {"org": "Org2", "scope_type": "library"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 0) - - def test_org_filter_absent_returns_all_permitted(self): - """When org param is absent, all permitted scopes are returned (existing behavior).""" - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - # Staff user sees all courses - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.COURSE_ORG1, external_keys) - self.assertIn(self.COURSE_ORG2, external_keys) - - def test_org_filter_combined_with_search(self): - """Org filter works together with search filter.""" - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"org": "Org1", "search": "Course", "scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.COURSE_ORG1, external_keys) - self.assertNotIn(self.COURSE_ORG2, external_keys) - - # ------------------------------------------------------------------ # - # Orgs filter # - # ------------------------------------------------------------------ # - - def test_orgs_filter_staff_courses(self): - """Staff user with orgs param sees only courses from that org.""" - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - for item in response.data["results"]: - self.assertIn("Org1", item["external_key"]) - # Org2 course should not appear - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertNotIn(self.COURSE_ORG2, external_keys) - - def test_orgs_filter_staff_libraries(self): - """Staff user with orgs param sees only libraries from that org.""" - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "library"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.LIBRARY_ORG2, external_keys) - self.assertNotIn(self.LIBRARY_ORG1, external_keys) - - def test_orgs_filter_staff_no_match(self): - """Staff user with orgs param for a non-existent org gets empty results.""" - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"orgs": "NonExistentOrg", "scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 0) - - def test_orgs_filter_non_staff_with_permission(self): - """Non-staff user with orgs param sees scopes only if they have permission for that org.""" - # regular_1 has LIBRARY_USER on lib:Org1:LIB1 → VIEW_LIBRARY_TEAM granted - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "library"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.LIBRARY_ORG1, external_keys) - - def test_orgs_filter_non_staff_without_permission(self): - """Non-staff user with org param for an org they have no permission for gets empty results.""" - # regular_1 has no permissions on Org2 - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "library"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 0) - - def test_orgs_filter_non_staff_courses(self): - """Non-staff user with orgs param sees only courses from that org if they have permission.""" - # regular_9 has COURSE_STAFF on COURSE_ORG1 → VIEW_COURSE_TEAM granted - user = User.objects.get(username="regular_9") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.COURSE_ORG1, external_keys) - - def test_orgs_filter_non_staff_courses_no_permission(self): - """Non-staff user with orgs param for an org they have no course permission for gets empty results.""" - # regular_9 has no course permissions on Org2 - user = User.objects.get(username="regular_9") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 0) - - def test_orgs_filter_with_glob_permission(self): - """Non-staff user with orgs glob permission and org filter sees only that org's scopes.""" - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*") - with patch( - "openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission", - return_value=[glob_scope], - ): - response = self.client.get(self.url, {"orgs": "Org1", "scope_type": "library"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.LIBRARY_ORG1, external_keys) - self.assertNotIn(self.LIBRARY_ORG2, external_keys) - - def test_orgs_filter_with_glob_permission_wrong_org(self): - """Non-staff user with org glob for Org1 but filtering by Org2 gets empty results.""" - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - self.build_qs_patcher.stop() - - glob_scope = OrgContentLibraryGlobData(external_key="lib:Org1:*") - with patch( - "openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission", - return_value=[glob_scope], - ): - response = self.client.get(self.url, {"orgs": "Org2", "scope_type": "library"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 0) - - def test_orgs_filter_combined_with_search(self): - """Orgs filter works together with search filter.""" - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"orgs": "Org1", "search": "Course", "scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.COURSE_ORG1, external_keys) - self.assertNotIn(self.COURSE_ORG2, external_keys) - - def test_orgs_filter_combined_with_org(self): - """Orgs filter works together with the singluar org filter.""" - self.build_qs_patcher.stop() - - response = self.client.get( - self.url, {"org": "Org2", "orgs": "Org1", "search": "Course", "scope_type": "course"} - ) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.COURSE_ORG1, external_keys) - self.assertIn(self.COURSE_ORG2, external_keys) - - def test_orgs_filter_with_multiple_orgs(self): - """Orgs filter with multiple orgs returns scopes from both orgs.""" - self.build_qs_patcher.stop() - - response = self.client.get(self.url, {"orgs": "Org1,Org2", "search": "Course", "scope_type": "course"}) - - self.build_qs_patcher.start() - self.assertEqual(response.status_code, status.HTTP_200_OK) - external_keys = [item["external_key"] for item in response.data["results"]] - self.assertIn(self.COURSE_ORG1, external_keys) - self.assertIn(self.COURSE_ORG2, external_keys) - - -@ddt -class TestAdminConsoleOrgsAPIView(ViewTestMixin): - """Test suite for AdminConsoleOrgsAPIView.""" - - @classmethod - def setUpClass(cls): - """Assign a course role to regular_9 for COURSES_VIEW_COURSE_TEAM permission tests.""" - super().setUpClass() - cls._assign_roles_to_users( - [ - { - "subject_name": "regular_9", - "role_name": roles.COURSE_STAFF.external_key, - "scope_name": COURSE_SCOPE_ORG1, - }, - ] - ) - - @classmethod - def setUpTestData(cls): - """Create Organization fixtures.""" - super().setUpTestData() - - Organization.objects.bulk_create( - [ - Organization(name="Alpha University", short_name="AlphaU"), - Organization(name="Beta Institute", short_name="BetaI"), - Organization(name="Gamma College", short_name="GammaC"), - ] - ) - - def setUp(self): - """Set up test fixtures.""" - super().setUp() - self.url = reverse("openedx_authz:orgs-list") - - def test_get_orgs_returns_all(self): - """Test that all orgs are returned when no search param is provided. - - Expected result: - - Returns 200 OK status - - Returns all 3 orgs - """ - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 3) - self.assertEqual(len(response.data["results"]), 3) - - @data( - # Match by name - ("Alpha", 1), - ("university", 1), - # Match by short_name - ("BetaI", 1), - ("gamma", 1), - # Partial match across multiple orgs - ("a", 3), - # No match - ("nonexistent", 0), - ) - @unpack - def test_get_orgs_search(self, search_term: str, expected_count: int): - """Test filtering orgs by name or short_name via the search param. - - Expected result: - - Returns 200 OK status - - Returns only orgs matching the search term - """ - response = self.client.get(self.url, {"search": search_term}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], expected_count) - self.assertEqual(len(response.data["results"]), expected_count) + self.assertEqual(response.data["valid_users"], expected_valid) + self.assertEqual(response.data["invalid_users"], expected_invalid) + self.assertEqual(response.data["summary"]["total"], len(input_users)) + self.assertEqual(response.data["summary"]["valid_count"], len(expected_valid)) + self.assertEqual(response.data["summary"]["invalid_count"], len(expected_invalid)) @data( - ({}, 3, False), - ({"page": 1, "page_size": 2}, 2, True), - ({"page": 2, "page_size": 2}, 1, False), - ({"page": 1, "page_size": 3}, 3, False), + # Mix of valid and invalid users + (["admin_1", "nonexistent_user"], ["admin_1"], ["nonexistent_user"]), + # Mix of valid and invalid with emails + (["admin_1@example.com", "fake@example.com"], ["admin_1@example.com"], ["fake@example.com"]), + # Mix of usernames and emails with some invalid + ( + ["admin_1", "fake@example.com", "regular_1@example.com"], + ["admin_1", "regular_1@example.com"], + ["fake@example.com"], + ), + # More complex mix + ( + ["admin_1", "nonexistent1", "regular_1@example.com", "nonexistent2"], + ["admin_1", "regular_1@example.com"], + ["nonexistent1", "nonexistent2"], + ), ) @unpack - def test_get_orgs_pagination(self, query_params: dict, expected_count: int, has_next: bool): - """Test pagination of org results. - - Expected result: - - Returns 200 OK status - - Returns correct page size and next link - """ - response = self.client.get(self.url, query_params) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data["results"]), expected_count) - if has_next: - self.assertIsNotNone(response.data["next"]) - else: - self.assertIsNone(response.data["next"]) - - def test_get_orgs_response_shape(self): - """Test that each org result contains the expected fields. - - Expected result: - - Each result has id, name, and short_name fields - """ - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - result = response.data["results"][0] - self.assertIn("id", result) - self.assertIn("name", result) - self.assertIn("short_name", result) - - def test_get_orgs_excludes_inactive(self): - """Test that inactive orgs are not returned. + def test_post_mixed_valid_invalid_users(self, input_users: list, expected_valid: list, expected_invalid: list): + """Test user validation when some users are valid and others are invalid. Expected result: - Returns 200 OK status - - Inactive orgs are excluded from results + - Valid users are in valid_users list + - Invalid users are in invalid_users list + - Summary contains correct counts """ - Organization.objects.create(name="Inactive Org", short_name="InactiveO", active=False) - - response = self.client.get(self.url) - + self.client.force_authenticate(user=self.admin_user) + request_data = {"users": input_users} + response = self.client.post(self.url, data=request_data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 3) - result_names = [org["name"] for org in response.data["results"]] - self.assertNotIn("Inactive Org", result_names) + self.assertEqual(set(response.data["valid_users"]), set(expected_valid)) + self.assertEqual(set(response.data["invalid_users"]), set(expected_invalid)) + self.assertEqual(response.data["summary"]["total"], len(input_users)) + self.assertEqual(response.data["summary"]["valid_count"], len(expected_valid)) + self.assertEqual(response.data["summary"]["invalid_count"], len(expected_invalid)) @data( - # Only VIEW_LIBRARY_TEAM (library_user role in a lib scope) - ("regular_1", status.HTTP_200_OK), - # Only COURSES_VIEW_COURSE_TEAM (course_staff role in a course scope) - ("regular_9", status.HTTP_200_OK), - # No relevant permissions - ("regular_10", status.HTTP_403_FORBIDDEN), - # Superuser - ("admin_1", status.HTTP_200_OK), - ) - @unpack - def test_get_orgs_permissions(self, username: str, expected_status: int): - """Test access control for AdminConsoleOrgsAPIView. - - Test cases: - - User with only VIEW_LIBRARY_TEAM (via library role): allowed - - User with only COURSES_VIEW_COURSE_TEAM (via course role): allowed - - User with neither permission: forbidden - - Superuser/staff: allowed - - Expected result: - - Returns appropriate status code based on user permissions - - """ - user = User.objects.get(username=username) - self.client.force_authenticate(user=user) - - response = self.client.get(self.url) - - self.assertEqual(response.status_code, expected_status) - - def test_get_orgs_user_with_both_permissions_allowed(self): - """Test that a user with both VIEW_LIBRARY_TEAM and COURSES_VIEW_COURSE_TEAM can access the endpoint. - - Expected result: - - Returns 200 OK status - """ - # regular_1 has library_user (VIEW_LIBRARY_TEAM); assign a course role too - self._assign_roles_to_users( - [ - { - "subject_name": "regular_1", - "role_name": roles.COURSE_STAFF.external_key, - "scope_name": COURSE_SCOPE_ORG1, - }, - ] - ) - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_get_orgs_unauthenticated(self): - """Test that unauthenticated requests are rejected. - - Expected result: - - Returns 401 UNAUTHORIZED status - - """ - self.client.force_authenticate(user=None) - - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - -@ddt -class TestTeamMembersAPIView(ViewTestMixin): - """ - Test suite for TeamMembersAPIView. - - Setup summary (from ViewTestMixin.setUpClass): - lib:Org1:LIB1 → admin_1 (library_admin), regular_1 (library_user), regular_2 (library_user) [3 users] - lib:Org2:LIB2 → admin_2 (library_user), regular_3 (library_user), regular_4 (library_user) [3 users] - lib:Org3:LIB3 → admin_3 (library_admin), regular_5 (library_admin), regular_6 (library_author), - regular_7 (library_contributor), regular_8 (library_user) [5 users] - - Total unique users with assignments: 11 - (admin_1..3 are staff/superuser; regular_1..8 are plain users) - - Visibility via filter_allowed_assignments: - - Staff/superuser: sees all 11 users (is_admin_or_superuser_check grants VIEW_LIBRARY_TEAM on lib scopes) - - regular_1 (library_user in Org1:LIB1): VIEW_LIBRARY_TEAM granted → sees Org1 members (3) - - regular_3 (library_user in Org2:LIB2): VIEW_LIBRARY_TEAM granted → sees Org2 members (3) - - regular_6 (library_author in Org3:LIB3): VIEW_LIBRARY_TEAM granted → sees Org3 members (5) - """ - - def setUp(self): - """Set up test fixtures.""" - super().setUp() - self.url = reverse("openedx_authz:user-list") - - # -------------------------------------------------------------------- # - # Visibility: calling user only sees assignments it has view access to # - # -------------------------------------------------------------------- # - - @data( - # Staff/superuser sees all users across all scopes - ("admin_1", status.HTTP_200_OK, 11), - # regular_1 has LIBRARY_USER in lib:Org1:LIB1 (VIEW_LIBRARY_TEAM granted) → sees only Org1 members - ("regular_1", status.HTTP_200_OK, 3), - # regular_3 has LIBRARY_USER in lib:Org2:LIB2 (VIEW_LIBRARY_TEAM granted) → sees only Org2 members - ("regular_3", status.HTTP_200_OK, 3), - # regular_6 has LIBRARY_AUTHOR in lib:Org3:LIB3 (VIEW_LIBRARY_TEAM granted) → sees only Org3 members - ("regular_6", status.HTTP_200_OK, 5), - # regular_9 has no assignments → 403 (AnyScopePermission requires at least one relevant permission) - ("regular_9", status.HTTP_403_FORBIDDEN, None), - ) - @unpack - def test_visibility_limited_to_accessible_scopes( - self, username: str, expected_status: int, expected_count: int | None - ): - """Calling user only sees assignments for scopes it has VIEW_*_TEAM access to. - - Expected result: - - Staff/superuser sees all users across all scopes. - - Regular users only see members of scopes they have VIEW_*_TEAM permission for. - - Users with no relevant permissions get 403. - """ - user = User.objects.get(username=username) - self.client.force_authenticate(user=user) - - response = self.client.get(self.url) - self.assertEqual(response.status_code, expected_status) - if expected_count is not None: - self.assertEqual(response.data["count"], expected_count) - - def test_unauthenticated_returns_401(self): - """Unauthenticated requests are rejected. - - Expected result: - - Returns 401 UNAUTHORIZED. - """ - self.client.force_authenticate(user=None) - - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - # -------------------------------------------------------------------- # - # Filter by scopes # - # -------------------------------------------------------------------- # - - @data( - # Single scope - ("lib:Org1:LIB1", 3), - ("lib:Org2:LIB2", 3), - ("lib:Org3:LIB3", 5), - # Multiple scopes (users are unique per scope, no overlap) - ("lib:Org1:LIB1,lib:Org2:LIB2", 6), - ("lib:Org1:LIB1,lib:Org3:LIB3", 8), - ("lib:Org1:LIB1,lib:Org2:LIB2,lib:Org3:LIB3", 11), - # Non-existent scope returns no results - ("lib:Org99:NOLIB", 0), - ) - @unpack - def test_filter_by_scopes(self, scopes: str, expected_count: int): - """Results are filtered to the requested scopes. - - Expected result: - - Only users with assignments in the given scope(s) are returned. - - Multiple scopes are OR-combined. - """ - response = self.client.get(self.url, {"scopes": scopes}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], expected_count) - - # ------------------------------------------------------------------ # - # Filter by orgs # - # ------------------------------------------------------------------ # - - @data( - # Single org - ("Org1", 3), - ("Org2", 3), - ("Org3", 5), - # Multiple orgs - ("Org1,Org2", 6), - ("Org1,Org3", 8), - ("Org1,Org2,Org3", 11), - # Non-existent org returns no results - ("OrgX", 0), - ) - @unpack - def test_filter_by_orgs(self, orgs: str, expected_count: int): - """Results are filtered to the requested orgs. - - Expected result: - - Only users with assignments in the given org(s) are returned. - - Multiple orgs are OR-combined. - """ - response = self.client.get(self.url, {"orgs": orgs}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], expected_count) - - # ------------------------------------------------------------------ # - # Search (username, full_name, email) # - # ------------------------------------------------------------------ # - - @data( - # Exact username match - ("admin_1", 1), - # Partial username match - ("admin", 3), - ("regular", 8), - # Email match - ("admin_1@example.com", 1), - ("@example.com", 11), - # No match - ("nonexistent", 0), - ) - @unpack - def test_search(self, search: str, expected_count: int): - """Search filters by username, full_name, or email (case-insensitive). - - Expected result: - - Returns only users whose username, full_name, or email contains the search term. - """ - response = self.client.get(self.url, {"search": search}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], expected_count) - - # ------------------------------------------------------------------ # - # Sorting # - # ------------------------------------------------------------------ # - - @data( - ("username", "asc"), - ("username", "desc"), - ("email", "asc"), - ("email", "desc"), - ("full_name", "asc"), - ("full_name", "desc"), - ) - @unpack - def test_sorting(self, sort_by: str, order: str): - """Results can be sorted by username, full_name, or email in asc/desc order. - - Expected result: - - Returns 200 OK. - - Results are ordered according to the requested field and direction. - """ - response = self.client.get(self.url, {"sort_by": sort_by, "order": order}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - values = [item[sort_by] for item in response.data["results"]] - expected = sorted(values, key=lambda v: (v or "").lower(), reverse=order == "desc") - self.assertEqual(values, expected) - - @data( - {"sort_by": "invalid"}, - {"order": "ascending"}, - {"order": "descending"}, - ) - def test_sorting_invalid_params(self, query_params: dict): - """Invalid sort_by or order values return 400. - - Expected result: - - Returns 400 BAD REQUEST. - """ - response = self.client.get(self.url, query_params) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - # ------------------------------------------------------------------ # - # Pagination # - # ------------------------------------------------------------------ # - - @data( - ({"page": 1, "page_size": 5}, 5, True), - ({"page": 2, "page_size": 5}, 5, True), - ({"page": 3, "page_size": 5}, 1, False), - ({"page": 1, "page_size": 11}, 11, False), - ({"page": 1, "page_size": 6}, 6, True), - ) - @unpack - def test_pagination(self, query_params: dict, expected_page_count: int, has_next: bool): - """Results are paginated correctly. - - Expected result: - - Returns 200 OK. - - Page contains the expected number of items. - - `next` link is present only when more pages exist. - """ - response = self.client.get(self.url, query_params) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 11) - self.assertEqual(len(response.data["results"]), expected_page_count) - if has_next: - self.assertIsNotNone(response.data["next"]) - else: - self.assertIsNone(response.data["next"]) - - # ------------------------------------------------------------------ # - # Response shape # - # ------------------------------------------------------------------ # - - def test_response_shape(self): - """Each result item contains the expected fields. - - Expected result: - - Returns 200 OK. - - Each item has username, full_name, email, and assignation_count. - """ - response = self.client.get(self.url, {"scopes": "lib:Org1:LIB1"}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - for item in response.data["results"]: - self.assertIn("username", item) - self.assertIn("full_name", item) - self.assertIn("email", item) - self.assertIn("assignation_count", item) - self.assertEqual(item["assignation_count"], 1) - - -@ddt -class TestTeamMemberAssignmentsAPIView(ViewTestMixin): - """ - Test suite for TeamMemberAssignmentsAPIView. - - Setup summary (from ViewTestMixin.setUpClass): - lib:Org1:LIB1 → admin_1 (library_admin), regular_1 (library_user), regular_2 (library_user) - lib:Org2:LIB2 → admin_2 (library_user), regular_3 (library_user), regular_4 (library_user) - lib:Org3:LIB3 → admin_3 (library_admin), regular_5 (library_admin), regular_6 (library_author), - regular_7 (library_contributor), regular_8 (library_user) - - URL: /api/authz/v1/users//assignments/ - Response fields per item: is_superadmin, role, org, scope, permission_count - - Superadmin entry: - admin_1..3 are staff/superusers. Querying any of them always adds one - SuperAdminAssignmentData entry: role="django.superuser" (or "django.staff"), - org="*", scope="*", permission_count=None, is_superadmin=True. - This entry is always included regardless of org/role filters, since those - filters are applied only to the role assignments, not to the superadmin entry. - - Visibility via filter_allowed_assignments: - - Staff/superuser: sees all role assignments for any user, plus the superadmin - entry when the target is a superadmin. - - regular_1 (library_user in Org1:LIB1): sees only Org1:LIB1 role assignments, - plus the superadmin entry when the target is a superadmin. - - regular_9 (no assignments): rejected with 403 by AnyScopePermission - (requires at least one VIEW_LIBRARY_TEAM or COURSES_VIEW_COURSE_TEAM permission). - """ - - def _url(self, username: str) -> str: - return reverse("openedx_authz:user-assignment-list", kwargs={"username": username}) - - # -------------------------------------------------------------------- # - # Visibility: calling user only sees assignments it has view access to # - # -------------------------------------------------------------------- # - - @data( - # Staff/superuser targets get 1 superadmin entry + their role assignment(s) - ("admin_1", "admin_1", status.HTTP_200_OK, 2), # superadmin entry + library_admin in Org1 - ("admin_1", "admin_2", status.HTTP_200_OK, 2), # superadmin entry + library_user in Org2 - ("admin_1", "admin_3", status.HTTP_200_OK, 2), # superadmin entry + library_admin in Org3 - # Regular user targets get only their role assignments (no superadmin entry) - ("admin_1", "regular_5", status.HTTP_200_OK, 1), - # The superadmin entry is always included for superadmin targets, visible to all callers - ( - "regular_1", - "admin_1", - status.HTTP_200_OK, - 2, - ), # superadmin entry + library_admin in Org1 (visible via Org1 access) - # regular_1 cannot see admin_2's Org2 role assignment, but superadmin entry is still included - ("regular_1", "admin_2", status.HTTP_200_OK, 1), # superadmin entry only - # regular_9 has no assignments → 403 (AnyScopePermission requires at least one relevant permission) - ("regular_9", "admin_1", status.HTTP_403_FORBIDDEN, None), - ) - @unpack - def test_visibility_limited_to_accessible_scopes( - self, caller: str, target: str, expected_status: int, expected_count: int | None - ): - """Calling user only sees role assignments for scopes it has view access to. - - The superadmin entry is always included when the target is a superadmin, - regardless of the calling user's permissions. - - Expected result: - - Superadmin targets always include the superadmin entry. - - Role assignments are filtered by the calling user's permissions. - - Regular user targets return only their visible role assignments. - - Users with no relevant permissions get 403. - """ - self.client.force_authenticate(user=User.objects.get(username=caller)) - - response = self.client.get(self._url(target)) - - self.assertEqual(response.status_code, expected_status) - if expected_count is not None: - self.assertEqual(response.data["count"], expected_count) - - def test_unauthenticated_returns_401(self): - """Unauthenticated requests are rejected. - - Expected result: - - Returns 401 UNAUTHORIZED. - """ - self.client.force_authenticate(user=None) - - response = self.client.get(self._url("admin_1")) - - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_unknown_user_returns_empty(self): - """Requesting assignments for a non-existent user returns an empty list. - - Expected result: - - Returns 200 OK with count 0. - """ - response = self.client.get(self._url("nonexistent_user")) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 0) - - # ------------------------------------------------------------------ # - # Filter by orgs # - # ------------------------------------------------------------------ # - - @data( - # admin_3 has library_admin in lib:Org3:LIB3; superadmin entry is always included - ("admin_3", "Org3", 2), # superadmin entry + Org3 role assignment - ("admin_3", "Org1", 1), # superadmin entry only (no Org1 role assignment) - # regular_5 has library_admin in lib:Org3:LIB3 (no superadmin entry) - ("regular_5", "Org3", 1), - ("regular_5", "Org1", 0), - # non-existent org: superadmin entry still included for admin targets - ("admin_1", "OrgX", 1), # superadmin entry only - ) - @unpack - def test_filter_by_orgs(self, target: str, orgs: str, expected_count: int): - """Results are filtered to the requested orgs. - - Expected result: - - Only assignments in the given org(s) are returned. - - Multiple orgs are OR-combined. - """ - response = self.client.get(self._url(target), {"orgs": orgs}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], expected_count) - - def test_filter_by_multiple_orgs(self): - """Multiple orgs are OR-combined. - - Expected result: - - Returns assignments matching any of the given orgs. - """ - # regular_6 has library_author in lib:Org3:LIB3 - # regular_7 has library_contributor in lib:Org3:LIB3 - # Use admin_1 (staff) to see all of regular_8's assignments - # regular_8 has library_user in lib:Org3:LIB3 only - response = self.client.get(self._url("regular_8"), {"orgs": "Org1,Org3"}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 1) - - # ------------------------------------------------------------------ # - # Filter by roles # - # ------------------------------------------------------------------ # - - @data( - # role filter applies only to role assignments; superadmin entry is always included for admin targets - ("admin_1", roles.LIBRARY_ADMIN.external_key, 2), # superadmin entry + library_admin - ("admin_1", roles.LIBRARY_USER.external_key, 1), # superadmin entry only - ("regular_5", roles.LIBRARY_ADMIN.external_key, 1), - ("regular_5", roles.LIBRARY_USER.external_key, 0), - ("regular_6", roles.LIBRARY_AUTHOR.external_key, 1), - ("regular_6", roles.LIBRARY_ADMIN.external_key, 0), - ("admin_1", "non_existent_role", 1), # superadmin entry only - ) - @unpack - def test_filter_by_roles(self, target: str, role_filter: str, expected_count: int): - """Results are filtered to the requested roles. - - Expected result: - - Only assignments with the given role(s) are returned. - """ - response = self.client.get(self._url(target), {"roles": role_filter}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], expected_count) - - def test_filter_by_multiple_roles(self): - """Multiple roles are OR-combined for role assignments; superadmin entry always included. - - Expected result: - - Returns assignments matching any of the given roles, plus the superadmin entry. - """ - # admin_3 has library_admin in Org3:LIB3; filter for admin + author returns - # 1 role assignment + 1 superadmin entry = 2 - response = self.client.get( - self._url("admin_3"), - {"roles": f"{roles.LIBRARY_ADMIN.external_key},{roles.LIBRARY_AUTHOR.external_key}"}, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 2) - - # ------------------------------------------------------------------ # - # Sorting # - # ------------------------------------------------------------------ # - - @data( - ("role", "asc"), - ("role", "desc"), - ("org", "asc"), - ("org", "desc"), - ("scope", "asc"), - ("scope", "desc"), - ) - @unpack - def test_sorting(self, sort_by: str, order: str): - """Results are sorted by role, org, or scope in asc/desc order. - - Uses admin_3, who has 2 items in the response: a superadmin entry - (role="django.superuser", org="*", scope="*") and a role assignment - (role="library_admin", org="Org3", scope="lib:Org3:LIB3"). With two - distinct values per field the sort order is non-trivial and verifiable. - - Expected result: - - Returns 200 OK. - - Results are ordered according to the requested field and direction. - """ - response = self.client.get(self._url("admin_3"), {"sort_by": sort_by, "order": order}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertGreater(len(response.data["results"]), 1) - values = [item[sort_by] for item in response.data["results"]] - expected = sorted(values, key=lambda v: (v or "").lower(), reverse=order == "desc") - self.assertEqual(values, expected) - - @data( - {"sort_by": "invalid"}, - {"sort_by": "username"}, - {"order": "ascending"}, - {"order": "descending"}, - ) - def test_sorting_invalid_params(self, query_params: dict): - """Invalid sort_by or order values return 400. - - Expected result: - - Returns 400 BAD REQUEST. - """ - response = self.client.get(self._url("admin_1"), query_params) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - # ------------------------------------------------------------------ # - # Pagination # - # ------------------------------------------------------------------ # - - @data( - ({"page": 1, "page_size": 1}, 1, True), - ({"page": 2, "page_size": 1}, 1, False), - ({"page": 1, "page_size": 2}, 2, False), - ) - @unpack - def test_pagination(self, query_params: dict, expected_page_count: int, has_next: bool): - """Results are paginated correctly. - - Assigns regular_8 a second role (library_admin in Org1:LIB1) so it has - 2 assignments visible to admin_1 (staff). - - Expected result: - - Returns 200 OK. - - Page contains the expected number of items. - - `next` link is present only when more pages exist. - """ - assign_role_to_user_in_scope("regular_8", roles.LIBRARY_ADMIN.external_key, "lib:Org1:LIB1") - - response = self.client.get(self._url("regular_8"), query_params) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data["results"]), expected_page_count) - if has_next: - self.assertIsNotNone(response.data["next"]) - else: - self.assertIsNone(response.data["next"]) - - def test_platform_glob_assignment_serializes_wildcard_org(self): - """User with platform glob role assignment returns org '*' in the API response. - - regular_10 is assigned course_staff on course-v1:* (all courses on the platform). - regular_9 is assigned course_admin on the same scope so they can view team - assignments for that platform-level glob. - """ - assign_role_to_user_in_scope("regular_10", roles.COURSE_STAFF.external_key, PLATFORM_COURSE_GLOB) - assign_role_to_user_in_scope("regular_9", roles.COURSE_ADMIN.external_key, PLATFORM_COURSE_GLOB) - - self.client.force_authenticate(user=User.objects.get(username="regular_9")) - response = self.client.get(self._url("regular_10")) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 1) - assignment = response.data["results"][0] - self.assertFalse(assignment["is_superadmin"]) - self.assertEqual(assignment["org"], "*") - self.assertEqual(assignment["scope"], PLATFORM_COURSE_GLOB) - self.assertEqual(assignment["role"], roles.COURSE_STAFF.external_key) - - # ------------------------------------------------------------------ # - # Response shape # - # ------------------------------------------------------------------ # - - def test_response_shape(self): - """Each result item contains the expected fields. - - admin_1 is a superuser, so the response contains two items: - - A superadmin entry with role="django.superuser", org="*", scope="*", - permission_count=None, is_superadmin=True - - A regular role assignment entry with concrete values and is_superadmin=False - - Expected result: - - Returns 200 OK. - - Each item has is_superadmin, role, org, scope, and permission_count. - """ - response = self.client.get(self._url("admin_1")) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 2) - - superadmin_item = next(item for item in response.data["results"] if item["is_superadmin"]) - self.assertIn(superadmin_item["role"], ("django.superuser", "django.staff")) - self.assertEqual(superadmin_item["org"], "*") - self.assertEqual(superadmin_item["scope"], "*") - self.assertIsNone(superadmin_item["permission_count"]) - - role_item = next(item for item in response.data["results"] if not item["is_superadmin"]) - self.assertIn("role", role_item) - self.assertIn("org", role_item) - self.assertIn("scope", role_item) - self.assertIn("permission_count", role_item) - self.assertEqual(role_item["role"], roles.LIBRARY_ADMIN.external_key) - self.assertEqual(role_item["org"], "Org1") - self.assertEqual(role_item["scope"], "lib:Org1:LIB1") - self.assertGreater(role_item["permission_count"], 0) - - -@ddt -class TestRoleListView(ViewTestMixin): - """Test suite for RoleListView.""" - - _COURSE_ASSIGNMENTS = [ - { - "subject_name": "course_admin", - "role_name": roles.COURSE_ADMIN.external_key, - "scope_name": COURSE_SCOPE_ORG1, - }, - { - "subject_name": "course_editor", - "role_name": roles.COURSE_EDITOR.external_key, - "scope_name": COURSE_SCOPE_ORG1, - }, - { - "subject_name": "course_auditor", - "role_name": roles.COURSE_AUDITOR.external_key, - "scope_name": COURSE_SCOPE_ORG1, - }, - ] - - @classmethod - def setUpClass(cls): - super().setUpClass() - cls._assign_roles_to_users(assignments=cls._COURSE_ASSIGNMENTS) - - def setUp(self): - """Set up test fixtures.""" - super().setUp() - self.client.force_authenticate(user=self.admin_user) - self.url = reverse("openedx_authz:role-list") - - def test_get_roles_success(self): - """Test retrieving role definitions and their permissions. - - Expected result: - - Returns 200 OK status - - Returns correct role definitions with permissions and user counts - """ - response = self.client.get(self.url, {"scope": "lib:Org1:LIB1"}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn("results", response.data) - self.assertIn("count", response.data) - self.assertEqual(len(response.data["results"]), response.data["count"]) - self.assertEqual(len(response.data["results"]), 4) - - @patch.object(api, "get_role_definitions_in_scope") - def test_get_roles_empty_result(self, mock_get_roles): - """Test retrieving roles when none exist in scope. - - Expected result: - - Returns 200 OK status - - Returns empty results list - """ - mock_get_roles.return_value = [] - - response = self.client.get(self.url, {"scope": "lib:Org1:LIB1"}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn("results", response.data) - self.assertIn("count", response.data) - self.assertEqual(response.data["count"], 0) - self.assertEqual(len(response.data["results"]), 0) - - @data( - {}, - {"custom_param": "custom_value"}, - {"custom_param": "a" * 256, "another_param": "custom_value"}, - ) - def test_get_roles_scope_is_missing(self, query_params: dict): - """Test retrieving roles with scope is missing. - - Expected result: - - Returns 400 BAD REQUEST status - """ - response = self.client.get(self.url, query_params) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn("required", [error.code for error in response.data["scope"]]) - - @data( - ({"scope": ""}, "blank"), - ({"scope": "a" * 256}, "max_length"), - ({"scope": "invalid"}, "invalid"), - ({"scope": "*"}, "invalid"), - ) - @unpack - def test_get_roles_scope_is_invalid(self, query_params: dict, error_code: str): - """Test retrieving roles with invalid scope. - - Expected result: - - Returns 400 BAD REQUEST status - """ - response = self.client.get(self.url, query_params) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn(error_code, [error.code for error in response.data["scope"]]) - - @data( - ({}, 4, False), - ({"page": 1, "page_size": 2}, 2, True), - ({"page": 2, "page_size": 2}, 2, False), - ({"page": 1, "page_size": 4}, 4, False), - ) - @unpack - def test_get_roles_pagination(self, query_params: dict, expected_count: int, has_next: bool): - """Test retrieving roles with pagination. - - Expected result: - - Returns 200 OK status - - Returns paginated results with correct page size - """ - query_params["scope"] = "lib:Org1:LIB1" - response = self.client.get(self.url, query_params) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn("results", response.data) - self.assertEqual(len(response.data["results"]), expected_count) - self.assertIn("next", response.data) - if has_next: - self.assertIsNotNone(response.data["next"]) - else: - self.assertIsNone(response.data["next"]) - - @data( - # Unauthenticated - (None, status.HTTP_401_UNAUTHORIZED), - # Admin user - ("admin_1", status.HTTP_200_OK), - # Library Admin user - ("regular_5", status.HTTP_200_OK), - # Library Author user - ("regular_6", status.HTTP_200_OK), - # Library Contributor user - ("regular_7", status.HTTP_200_OK), - # Library User user - ("regular_8", status.HTTP_200_OK), - # Regular user without permission - ("regular_9", status.HTTP_403_FORBIDDEN), - # Non existent user - ("non_existent_user", status.HTTP_401_UNAUTHORIZED), - ) - @unpack - def test_get_roles_permissions(self, username: str, status_code: int): - """Test retrieving roles with permissions. - - Expected result: - - Returns 401 UNAUTHORIZED status if user is not authenticated - - Returns 403 FORBIDDEN status if user does not have permission - - Returns 200 OK status if user has permission with correct roles with permissions and user counts - """ - user = User.objects.filter(username=username).first() - self.client.force_authenticate(user=user) - - response = self.client.get(self.url, {"scope": "lib:Org3:LIB3"}) - - self.assertEqual(response.status_code, status_code) - if status_code == status.HTTP_200_OK: - self.assertIn("results", response.data) - self.assertIn("count", response.data) - - # --- Course scope equivalents --- - - @data( - # Unauthenticated - (None, status.HTTP_401_UNAUTHORIZED), - # Django superuser always passes - ("admin_1", status.HTTP_200_OK), - # course_admin has COURSES_MANAGE_COURSE_TEAM ⊇ COURSES_VIEW_COURSE_TEAM - ("course_admin", status.HTTP_200_OK), - # course_auditor has COURSES_VIEW_COURSE_TEAM - ("course_auditor", status.HTTP_200_OK), - # Library-only user has no course permission - ("regular_9", status.HTTP_403_FORBIDDEN), - ) - @unpack - def test_get_roles_course_permissions(self, username: str, status_code: int): - """Mirror of test_get_roles_permissions for course scopes. - - Expected result: - - Returns appropriate status code based on course-scope permissions. - """ - user = User.objects.filter(username=username).first() - self.client.force_authenticate(user=user) - - response = self.client.get(self.url, {"scope": COURSE_SCOPE_ORG1}) - - self.assertEqual(response.status_code, status_code) - - -@ddt -class TestUserValidationAPIView(ViewTestMixin): - """Test suite for UserValidationAPIView.""" - - def setUp(self): - """Set up test fixtures.""" - super().setUp() - self.url = reverse("openedx_authz:user-validation") - - @data( - # All users valid - usernames - (["admin_1", "regular_1"], ["admin_1", "regular_1"], []), - # All users valid - emails - (["admin_1@example.com", "regular_1@example.com"], ["admin_1@example.com", "regular_1@example.com"], []), - # Mixed usernames and emails - (["admin_1", "regular_1@example.com"], ["admin_1", "regular_1@example.com"], []), - # Single user - (["admin_1"], ["admin_1"], []), - ) - @unpack - def test_post_all_users_valid(self, input_users: list, expected_valid: list, expected_invalid: list): - """Test user validation when all users are valid. - - Expected result: - - Returns 200 OK status - - All users are in valid_users list - - invalid_users list is empty - - Summary contains correct counts - """ - self.client.force_authenticate(user=self.admin_user) - request_data = {"users": input_users} - response = self.client.post(self.url, data=request_data, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["valid_users"], expected_valid) - self.assertEqual(response.data["invalid_users"], expected_invalid) - self.assertEqual(response.data["summary"]["total"], len(input_users)) - self.assertEqual(response.data["summary"]["valid_count"], len(expected_valid)) - self.assertEqual(response.data["summary"]["invalid_count"], len(expected_invalid)) - - @data( - # Mix of valid and invalid users - (["admin_1", "nonexistent_user"], ["admin_1"], ["nonexistent_user"]), - # Mix of valid and invalid with emails - (["admin_1@example.com", "fake@example.com"], ["admin_1@example.com"], ["fake@example.com"]), - # Mix of usernames and emails with some invalid - ( - ["admin_1", "fake@example.com", "regular_1@example.com"], - ["admin_1", "regular_1@example.com"], - ["fake@example.com"], - ), - # More complex mix - ( - ["admin_1", "nonexistent1", "regular_1@example.com", "nonexistent2"], - ["admin_1", "regular_1@example.com"], - ["nonexistent1", "nonexistent2"], - ), - ) - @unpack - def test_post_mixed_valid_invalid_users(self, input_users: list, expected_valid: list, expected_invalid: list): - """Test user validation when some users are valid and others are invalid. - - Expected result: - - Returns 200 OK status - - Valid users are in valid_users list - - Invalid users are in invalid_users list - - Summary contains correct counts - """ - self.client.force_authenticate(user=self.admin_user) - request_data = {"users": input_users} - response = self.client.post(self.url, data=request_data, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(set(response.data["valid_users"]), set(expected_valid)) - self.assertEqual(set(response.data["invalid_users"]), set(expected_invalid)) - self.assertEqual(response.data["summary"]["total"], len(input_users)) - self.assertEqual(response.data["summary"]["valid_count"], len(expected_valid)) - self.assertEqual(response.data["summary"]["invalid_count"], len(expected_invalid)) - - @data( - # All users invalid - (["nonexistent1", "nonexistent2"], [], ["nonexistent1", "nonexistent2"]), - # All invalid emails - (["fake1@example.com", "fake2@example.com"], [], ["fake1@example.com", "fake2@example.com"]), - # Single invalid user - (["nonexistent_user"], [], ["nonexistent_user"]), - # Single invalid email - (["fake@example.com"], [], ["fake@example.com"]), - ) - @unpack - def test_post_all_users_invalid(self, input_users: list, expected_valid: list, expected_invalid: list): - """Test user validation when all users are invalid. - - Expected result: - - Returns 200 OK status - - valid_users list is empty - - All users are in invalid_users list - - Summary contains correct counts - """ - self.client.force_authenticate(user=self.admin_user) - request_data = {"users": input_users} - response = self.client.post(self.url, data=request_data, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["valid_users"], expected_valid) - self.assertEqual(set(response.data["invalid_users"]), set(expected_invalid)) - self.assertEqual(response.data["summary"]["total"], len(input_users)) - self.assertEqual(response.data["summary"]["valid_count"], len(expected_valid)) - self.assertEqual(response.data["summary"]["invalid_count"], len(expected_invalid)) - - @data( - # Missing users field - {}, - {"other_field": "value"}, - # Empty users list (not allowed by serializer) - {"users": []}, - # Invalid data types - {"users": "not_a_list"}, - {"users": [{"not": "string"}]}, - # Null values - {"users": None}, - {"users": [None, "admin_1"]}, - # Users with strings too long (over 255 characters) - {"users": ["a" * 256]}, - ) - def test_post_invalid_request_data(self, request_data: dict): - """Test user validation with invalid request data. - - Test cases: - - Missing required fields - - Empty users list (not allowed) - - Invalid data types - - Null values - - Strings exceeding max length - - Expected result: - - Returns 400 BAD REQUEST status - """ - self.client.force_authenticate(user=self.admin_user) - response = self.client.post(self.url, data=request_data, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - @data( - # Unauthenticated request - (None, status.HTTP_401_UNAUTHORIZED), - # Admin user with proper permissions (superuser) - ("admin_1", status.HTTP_200_OK), - # Regular user without required permissions (only LIBRARY_USER) - ("regular_1", status.HTTP_403_FORBIDDEN), - # Regular user with LIBRARY_ADMIN role (has MANAGE_LIBRARY_TEAM permission) - ("regular_5", status.HTTP_200_OK), - ) - @unpack - def test_post_authentication_and_permissions(self, username: str, expected_status: int): - """Test user validation with different authentication and permission scenarios. - - Expected result: - - Returns 401 UNAUTHORIZED for unauthenticated requests - - Returns 403 FORBIDDEN for authenticated users without permissions - - Returns 200 OK for users with proper permissions - """ - if username: - user = User.objects.get(username=username) - self.client.force_authenticate(user=user) - else: - self.client.force_authenticate(user=None) - request_data = {"users": ["admin_1", "regular_1"]} - response = self.client.post(self.url, data=request_data, format="json") - self.assertEqual(response.status_code, expected_status) - if expected_status == status.HTTP_200_OK: - self.assertIn("valid_users", response.data) - self.assertIn("invalid_users", response.data) - self.assertIn("summary", response.data) - self.assertIn("total", response.data["summary"]) - self.assertIn("valid_count", response.data["summary"]) - self.assertIn("invalid_count", response.data["summary"]) - - def test_post_serializer_deduplication(self): - """Test that serializer properly deduplicates users while preserving order. - - The serializer automatically removes duplicates using dict.fromkeys(). - - Expected result: - - Returns 200 OK status - - Duplicates are automatically removed by the serializer - - Order is preserved for first occurrence - """ - self.client.force_authenticate(user=self.admin_user) - request_data = {"users": ["admin_1", "admin_1", "nonexistent", "nonexistent", "regular_1"]} - response = self.client.post(self.url, data=request_data, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["valid_users"], ["admin_1", "regular_1"]) - self.assertEqual(response.data["invalid_users"], ["nonexistent"]) - self.assertEqual(response.data["summary"]["total"], 3) - self.assertEqual(response.data["summary"]["valid_count"], 2) - self.assertEqual(response.data["summary"]["invalid_count"], 1) - - def test_post_large_user_list(self): - """Test user validation with a large list of users. - - Expected result: - - Returns 200 OK status - - Correctly processes all users in the list - - Response structure is maintained - """ - self.client.force_authenticate(user=self.admin_user) - valid_users = ["admin_1", "admin_2", "regular_1", "regular_2"] - invalid_users = [f"nonexistent_{i}" for i in range(10)] - all_users = valid_users + invalid_users - request_data = {"users": all_users} - response = self.client.post(self.url, data=request_data, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(set(response.data["valid_users"]), set(valid_users)) - self.assertEqual(set(response.data["invalid_users"]), set(invalid_users)) - self.assertEqual(response.data["summary"]["total"], len(all_users)) - self.assertEqual(response.data["summary"]["valid_count"], len(valid_users)) - self.assertEqual(response.data["summary"]["invalid_count"], len(invalid_users)) - - def test_post_response_serializer_structure(self): - """Test that response matches UserValidationAPIViewResponseSerializer structure. - - Expected result: - - Returns 200 OK status - - Response contains all required fields - - Field types match serializer definition - """ - self.client.force_authenticate(user=self.admin_user) - request_data = {"users": ["admin_1", "nonexistent"]} - response = self.client.post(self.url, data=request_data, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - required_fields = ["valid_users", "invalid_users", "summary"] - for field in required_fields: - self.assertIn(field, response.data) - summary_fields = ["total", "valid_count", "invalid_count"] - for field in summary_fields: - self.assertIn(field, response.data["summary"]) - self.assertIsInstance(response.data["summary"][field], int) - self.assertIsInstance(response.data["valid_users"], list) - self.assertIsInstance(response.data["invalid_users"], list) - - def test_post_inactive_user_validation(self): - """Test that inactive users are returned as invalid. - - Expected result: - - Inactive users appear in invalid_users list - - Summary counts reflect inactive users as invalid - - Active users appear in valid_users list - """ - User.objects.create(username="inactive_user", email="inactive@example.com", is_active=False) - self.client.force_authenticate(user=self.admin_user) - request_data = {"users": ["inactive_user", "inactive@example.com", "admin_1"]} - response = self.client.post(self.url, data=request_data, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn("inactive_user", response.data["invalid_users"]) - self.assertIn("inactive@example.com", response.data["invalid_users"]) - self.assertIn("admin_1", response.data["valid_users"]) - self.assertEqual(response.data["summary"]["total"], 3) - self.assertEqual(response.data["summary"]["valid_count"], 1) - self.assertEqual(response.data["summary"]["invalid_count"], 2) - - def test_post_with_validate_users_exception(self): - """Test handling of unexpected exceptions from validate_users.""" - self.client.force_authenticate(user=self.admin_user) - with patch.object(api, "validate_users") as mock_validate_users: - mock_validate_users.side_effect = Exception("Database connection error") - request_data = {"users": ["admin_1"]} - response = self.client.post(self.url, data=request_data, format="json") - self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) - self.assertEqual(response.data["message"], "An error occurred while validating users") - - def test_post_global_permission_inheritance(self): - """Test that UserValidationAPIView properly inherits from AnyScopePermission class.""" - self.assertIn(AnyScopePermission, UserValidationAPIView.permission_classes) - - def test_post_multiple_roles_user_access(self): - """Test access for a user with multiple roles that include management permissions.""" - test_user = User.objects.create(username="multi_role_user", email="multi@example.com") - assign_role_to_user_in_scope( - user_external_key="multi_role_user", - role_external_key=roles.LIBRARY_ADMIN.external_key, - scope_external_key="lib:Org1:LIB1", - ) - assign_role_to_user_in_scope( - user_external_key="multi_role_user", - role_external_key=roles.LIBRARY_USER.external_key, - scope_external_key="lib:Org2:LIB2", - ) - self.client.force_authenticate(user=test_user) - request_data = {"users": ["admin_1", "regular_1"]} - response = self.client.post(self.url, data=request_data, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_post_empty_role_assignments_denied(self): - """Test that a user with no role assignments is properly denied access.""" - test_user = User.objects.create(username="no_roles_user", email="noroles@example.com") - self.client.force_authenticate(user=test_user) - request_data = {"users": ["admin_1", "regular_1"]} - response = self.client.post(self.url, data=request_data, format="json") - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - -@ddt -class TestAssignmentsAPIView(ViewTestMixin): - """ - Test suite for AssignmentsAPIView. - - Setup summary (from ViewTestMixin.setUpClass): - lib:Org1:LIB1 → admin_1 (library_admin), regular_1 (library_user), regular_2 (library_user) - lib:Org2:LIB2 → admin_2 (library_user), regular_3 (library_user), regular_4 (library_user) - lib:Org3:LIB3 → admin_3 (library_admin), regular_5 (library_admin), regular_6 (library_author), - regular_7 (library_contributor), regular_8 (library_user) - - URL: /api/authz/v1/assignments/ - Response fields per item: is_superadmin, role, org, scope, permission_count, full_name, username, email - - This endpoint returns one row per (user, assignment) pair — i.e. assignments are - "unpacked" so each row carries user info alongside the assignment fields. - Total rows when called by a staff user with no filters: - + 11 role assignments (see setup above) - = 11 rows - - Visibility via get_visible_role_assignments_for_user: - - Staff/superuser: sees all role assignments across all scopes. - - regular_1 (library_user in Org1:LIB1): sees only Org1:LIB1 assignments (3). - - regular_9 (no assignments): sees no role assignments. - """ - - def setUp(self): - """Set up test fixtures.""" - super().setUp() - self.url = reverse("openedx_authz:assignment-list") - - # -------------------------------------------------------------------- # - # Visibility: calling user only sees assignments it has view access to # - # -------------------------------------------------------------------- # - - @data( - # Staff/superuser sees all: 11 role assignments - ("admin_1", status.HTTP_200_OK, 11), - # regular_1 has LIBRARY_USER in lib:Org1:LIB1 → sees 3 Org1 role assignments - ("regular_1", status.HTTP_200_OK, 3), - # regular_3 has LIBRARY_USER in lib:Org2:LIB2 → sees 3 Org2 role assignments - ("regular_3", status.HTTP_200_OK, 3), - # regular_6 has LIBRARY_AUTHOR in lib:Org3:LIB3 → sees 5 Org3 role assignments - ("regular_6", status.HTTP_200_OK, 5), - # regular_9 has no assignments → 403 (AnyScopePermission requires at least one relevant permission) - ("regular_9", status.HTTP_403_FORBIDDEN, None), - ) - @unpack - def test_visibility_limited_to_accessible_scopes( - self, username: str, expected_status: int, expected_count: int | None - ): - """Calling user only sees role assignments for scopes it has view access to. - - Users with no VIEW_LIBRARY_TEAM or COURSES_VIEW_COURSE_TEAM permission in any scope - are rejected with 403 by AnyScopePermission. - - Expected result: - - Staff/superuser sees all role assignments. - - Regular users see only assignments for their accessible scopes. - - Users with no relevant permissions get 403. - """ - user = User.objects.get(username=username) - self.client.force_authenticate(user=user) - - response = self.client.get(self.url) - - self.assertEqual(response.status_code, expected_status) - if expected_count is not None: - self.assertEqual(response.data["count"], expected_count) - - def test_unauthenticated_returns_401(self): - """Unauthenticated requests are rejected. - - Expected result: - - Returns 401 UNAUTHORIZED. - """ - self.client.force_authenticate(user=None) - - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - # ------------------------------------------------------------------ # - # Filter by orgs # - # ------------------------------------------------------------------ # - - @data( - ("Org1", 3), - ("Org2", 3), - ("Org3", 5), - ("OrgX", 0), - ) - @unpack - def test_filter_by_orgs(self, orgs: str, expected_count: int): - """Results are filtered to the requested orgs. - - Expected result: - - Only role assignments in the given org(s) are returned. - """ - response = self.client.get(self.url, {"orgs": orgs}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], expected_count) - - def test_filter_by_multiple_orgs(self): - """Multiple orgs are OR-combined. - - Expected result: - - Returns role assignments matching any of the given orgs. - """ - # Org1 has 3 role assignments, Org2 has 3 → 6 - response = self.client.get(self.url, {"orgs": "Org1,Org2"}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 6) - - # ------------------------------------------------------------------ # - # Filter by roles # - # ------------------------------------------------------------------ # - - @data( - # library_admin: admin_1 (Org1), admin_3 (Org3), regular_5 (Org3) = 3 - (roles.LIBRARY_ADMIN.external_key, 3), - # library_user: admin_2 (Org2), regular_1..4 (Org1+Org2), regular_8 (Org3) = 6 - (roles.LIBRARY_USER.external_key, 6), - # library_author: regular_6 (Org3) = 1 - (roles.LIBRARY_AUTHOR.external_key, 1), - # library_contributor: regular_7 (Org3) = 1 - ("library_contributor", 1), - # Non-existent role: no matches - ("non_existent_role", 0), - ) - @unpack - def test_filter_by_roles(self, role_filter: str, expected_count: int): - """Results are filtered to the requested roles. - - Expected result: - - Only role assignments with the given role(s) are returned. - """ - response = self.client.get(self.url, {"roles": role_filter}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], expected_count) - - def test_filter_by_multiple_roles(self): - """Multiple roles are OR-combined. - - Expected result: - - Returns role assignments matching any of the given roles. - """ - # library_admin (3) + library_author (1) = 4 - response = self.client.get( - self.url, - {"roles": f"{roles.LIBRARY_ADMIN.external_key},{roles.LIBRARY_AUTHOR.external_key}"}, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 4) - - # ------------------------------------------------------------------ # - # Filter by scopes # - # ------------------------------------------------------------------ # - - @data( - ("lib:Org1:LIB1", 3), - ("lib:Org2:LIB2", 3), - ("lib:Org3:LIB3", 5), - ("lib:Org99:NOLIB", 0), - ) - @unpack - def test_filter_by_scopes(self, scopes: str, expected_count: int): - """Results are filtered to the requested scopes. - - Expected result: - - Only role assignments in the given scope(s) are returned. - """ - response = self.client.get(self.url, {"scopes": scopes}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], expected_count) - - def test_filter_by_multiple_scopes(self): - """Multiple scopes are OR-combined. - - Expected result: - - Returns role assignments matching any of the given scopes. - """ - # Org1 (3) + Org2 (3) = 6 - response = self.client.get(self.url, {"scopes": "lib:Org1:LIB1,lib:Org2:LIB2"}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 6) - - # ------------------------------------------------------------------ # - # Search (full_name, username, email) # - # ------------------------------------------------------------------ # - - @data( - # Exact username match — admin_1 has 1 role assignment - ("admin_1", 1), - # Partial username match — "admin" matches admin_1, admin_2, admin_3, each with 1 role assignment = 3 - ("admin", 3), - # Partial username match — "regular" matches regular_1..8 (8 role assignments) - ("regular", 8), - # Email match - ("admin_1@example.com", 1), - # Partial email match — all 11 users have @example.com - ("@example.com", 11), - # No match - ("nonexistent", 0), - ) - @unpack - def test_search(self, search: str, expected_count: int): - """Search filters by full_name, username, or email (case-insensitive). - - Expected result: - - Returns only assignments whose user's full_name, username, or email - contains the search term. - """ - response = self.client.get(self.url, {"search": search}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], expected_count) - - def test_search_case_insensitive(self): - """Search is case-insensitive. - - Expected result: - - Uppercase and lowercase search terms return the same results. - """ - response_lower = self.client.get(self.url, {"search": "admin_1"}) - response_upper = self.client.get(self.url, {"search": "ADMIN_1"}) - - self.assertEqual(response_lower.status_code, status.HTTP_200_OK) - self.assertEqual(response_upper.status_code, status.HTTP_200_OK) - self.assertEqual(response_lower.data["count"], response_upper.data["count"]) - - # ------------------------------------------------------------------ # - # Sorting # - # ------------------------------------------------------------------ # - - @data( - ("role", "asc"), - ("role", "desc"), - ("org", "asc"), - ("org", "desc"), - ("scope", "asc"), - ("scope", "desc"), - ("full_name", "asc"), - ("full_name", "desc"), - ("username", "asc"), - ("username", "desc"), - ("email", "asc"), - ("email", "desc"), - ) - @unpack - def test_sorting(self, sort_by: str, order: str): - """Results can be sorted by role, org, scope, full_name, username, or email. - - Expected result: - - Returns 200 OK. - - Results are ordered according to the requested field and direction. - """ - response = self.client.get(self.url, {"sort_by": sort_by, "order": order}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertGreater(len(response.data["results"]), 1) - values = [item[sort_by] for item in response.data["results"]] - expected = sorted(values, key=lambda v: (v or "").lower(), reverse=order == "desc") - self.assertEqual(values, expected) - - @data( - {"sort_by": "invalid"}, - {"sort_by": "permission_count"}, - {"order": "ascending"}, - {"order": "descending"}, - ) - def test_sorting_invalid_params(self, query_params: dict): - """Invalid sort_by or order values return 400. - - Expected result: - - Returns 400 BAD REQUEST. - """ - response = self.client.get(self.url, query_params) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - # ------------------------------------------------------------------ # - # Pagination # - # ------------------------------------------------------------------ # - - @data( - # Total is 11 role assignments - ({"page": 1, "page_size": 5}, 5, True), - ({"page": 2, "page_size": 5}, 5, True), - ({"page": 3, "page_size": 5}, 1, False), - ({"page": 1, "page_size": 11}, 11, False), - ({"page": 1, "page_size": 7}, 7, True), - ({"page": 2, "page_size": 7}, 4, False), - ) - @unpack - def test_pagination(self, query_params: dict, expected_page_count: int, has_next: bool): - """Results are paginated correctly. - - Expected result: - - Returns 200 OK. - - Page contains the expected number of items. - - `next` link is present only when more pages exist. - """ - response = self.client.get(self.url, query_params) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 11) - self.assertEqual(len(response.data["results"]), expected_page_count) - if has_next: - self.assertIsNotNone(response.data["next"]) - else: - self.assertIsNone(response.data["next"]) - - # ------------------------------------------------------------------ # - # Response shape # - # ------------------------------------------------------------------ # - - def test_response_shape(self): - """Each result item contains the expected fields. - - Expected result: - - Returns 200 OK. - - Each item has is_superadmin, role, org, scope, permission_count, - full_name, username, and email. - """ - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - expected_fields = { - "is_superadmin", - "role", - "org", - "scope", - "permission_count", - "full_name", - "username", - "email", - } - for item in response.data["results"]: - self.assertEqual(set(item.keys()), expected_fields) - - def test_response_shape_role_assignment_entry(self): - """Role assignment entries have the expected field values. - - Expected result: - - Role assignment entries have is_superadmin=False, concrete role/org/scope - values, a non-null permission_count, and populated user fields. - """ - # Filter to a single scope to get predictable results - response = self.client.get(self.url, {"scopes": "lib:Org1:LIB1"}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - role_items = [item for item in response.data["results"] if not item["is_superadmin"]] - self.assertGreater(len(role_items), 0) - for item in role_items: - self.assertFalse(item["is_superadmin"]) - self.assertIn("role", item) - self.assertEqual(item["org"], "Org1") - self.assertEqual(item["scope"], "lib:Org1:LIB1") - self.assertIsNotNone(item["permission_count"]) - self.assertGreater(item["permission_count"], 0) - self.assertTrue(item["username"]) - self.assertTrue(item["email"]) - - # ------------------------------------------------------------------ # - # Superadmin entries # - # ------------------------------------------------------------------ # - - def test_no_superadmin_entries_in_response(self): - """The list endpoint never returns superadmin entries. - - staff/superuser entries are not backed by Casbin policies and do not - pass through the standard filter logic. They are excluded from this - endpoint entirely. - - Expected result: - - All items in the response have is_superadmin=False. - """ - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - for item in response.data["results"]: - self.assertFalse(item["is_superadmin"]) - - def test_no_superadmin_entries_when_filtering_by_org(self): - """No superadmin entries appear even when an org filter is active. - - Expected result: - - No items with is_superadmin=True in the response. - """ - response = self.client.get(self.url, {"orgs": "NonExistentOrg"}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - superadmin_items = [item for item in response.data["results"] if item["is_superadmin"]] - self.assertEqual(len(superadmin_items), 0) - - def test_no_superadmin_entries_when_filtering_by_role(self): - """No superadmin entries appear even when a role filter is active. - - Expected result: - - No items with is_superadmin=True in the response. - """ - response = self.client.get(self.url, {"roles": roles.LIBRARY_ADMIN.external_key}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - superadmin_items = [item for item in response.data["results"] if item["is_superadmin"]] - self.assertEqual(len(superadmin_items), 0) - - def test_no_superadmin_entries_when_filtering_by_scope(self): - """No superadmin entries appear even when a scope filter is active. - - Expected result: - - No items with is_superadmin=True in the response. - """ - response = self.client.get(self.url, {"scopes": "lib:Org1:LIB1"}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - superadmin_items = [item for item in response.data["results"] if item["is_superadmin"]] - self.assertEqual(len(superadmin_items), 0) - - def test_superadmin_user_search_returns_only_role_assignments(self): - """Searching for a superadmin user returns only their role assignments. - - admin_1 is staff/superuser and has one role assignment (library_admin in Org1). - The endpoint returns role assignments only, so the search result should be 1. - - Expected result: - - Count is 1. - - The result belongs to admin_1. - """ - response = self.client.get(self.url, {"search": "admin_1"}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 1) - usernames = {item["username"] for item in response.data["results"]} - self.assertEqual(usernames, {"admin_1"}) - - def test_unprivileged_user_gets_403(self): - """A user with no relevant permissions is rejected by AnyScopePermission. - - Expected result: - - Returns 403 FORBIDDEN. - """ - user = User.objects.get(username="regular_9") - self.client.force_authenticate(user=user) - - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - # ------------------------------------------------------------------ # - # Combined filters # - # ------------------------------------------------------------------ # - - def test_combined_org_and_role_filter(self): - """Org and role filters can be combined. - - Expected result: - - Only role assignments matching both the org and role are returned. - """ - # library_admin in Org1 = admin_1 (1 assignment) - response = self.client.get( - self.url, - {"orgs": "Org1", "roles": roles.LIBRARY_ADMIN.external_key}, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 1) - - def test_combined_scope_and_search(self): - """Scope filter and search can be combined. - - Expected result: - - Results are filtered by scope first, then search is applied. - """ - # Org1 has admin_1, regular_1, regular_2 → 3 role assignments - # Search "regular" matches regular_1, regular_2 → 2 results - response = self.client.get( - self.url, - {"scopes": "lib:Org1:LIB1", "search": "regular"}, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 2) - - # ------------------------------------------------------------------ # - # Active user filtering # - # ------------------------------------------------------------------ # - - def test_inactive_users_excluded_from_results(self): - """Role assignments for inactive users are not included in results. - - Deactivating a user (is_active=False) should remove their role assignments - from the response, even though the assignments still exist in the database. - - Expected result: - - Returns 200 OK. - - The inactive user's assignments do not appear in the results. - - The total count decreases by the number of assignments the inactive user had. - """ - # Baseline: admin_1 (staff) sees all 11 role assignments - baseline_response = self.client.get(self.url) - self.assertEqual(baseline_response.status_code, status.HTTP_200_OK) - baseline_count = baseline_response.data["count"] - - # Deactivate regular_1, who has 1 role assignment in lib:Org1:LIB1 - inactive_user = User.objects.get(username="regular_1") - inactive_user.is_active = False - inactive_user.save() - try: - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - # regular_1 had 1 role assignment → count should drop by 1 - self.assertEqual(response.data["count"], baseline_count - 1) - # Confirm regular_1 is not in the results - usernames = {item["username"] for item in response.data["results"]} - self.assertNotIn("regular_1", usernames) - finally: - inactive_user.is_active = True - inactive_user.save() - - -@ddt -class TestAssignmentsAPIViewPermissions(ViewTestMixin): - """ - Test suite for AssignmentsAPIView calling-user permission scenarios. - - This class extends the base ViewTestMixin setup with course-scope assignments - to test cross-scope visibility rules. - - Base setup (from ViewTestMixin.setUpClass) — library scopes only: - lib:Org1:LIB1 → admin_1 (library_admin), regular_1 (library_user), regular_2 (library_user) - lib:Org2:LIB2 → admin_2 (library_user), regular_3 (library_user), regular_4 (library_user) - lib:Org3:LIB3 → admin_3 (library_admin), regular_5 (library_admin), regular_6 (library_author), - regular_7 (library_contributor), regular_8 (library_user) - - Additional course-scope assignments (added in this class): - course-v1:Org1+COURSE1+2024 → regular_9 (course_staff), regular_10 (course_auditor) - - Permission model: - - Library scopes require VIEW_LIBRARY_TEAM to be visible. - - Course scopes require COURSES_VIEW_COURSE_TEAM to be visible. - - Superadmins (staff/superuser) bypass all permission checks and see everything. - - Superadmin entries (from get_superadmin_assignments) are always included for all callers. - - Total role assignments after setup: - 11 library assignments + 2 course assignments = 13 role assignments - + 3 superadmin entries (admin_1, admin_2, admin_3) - = 16 total rows for a superadmin caller with no filters. - """ - - @classmethod - def setUpClass(cls): - """Add course-scope assignments on top of the base library assignments.""" - super().setUpClass() - cls._assign_roles_to_users( - [ - { - "subject_name": "regular_9", - "role_name": roles.COURSE_STAFF.external_key, - "scope_name": COURSE_SCOPE_ORG1, - }, - { - "subject_name": "regular_10", - "role_name": roles.COURSE_AUDITOR.external_key, - "scope_name": COURSE_SCOPE_ORG1, - }, - ] - ) - - def setUp(self): - """Set up test fixtures.""" - super().setUp() - self.url = reverse("openedx_authz:assignment-list") - - def test_superadmin_sees_all_assignments(self): - """A superadmin caller sees all role assignments across all scope types. - - admin_1 is staff/superuser and bypasses all permission checks. The list - endpoint does not include superadmin entries, only role assignments. - - Expected result: - - Returns 200 OK. - - Sees all 13 role assignments (no superadmin entries). - """ - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 13) - - def test_superadmin_sees_library_and_course_assignments(self): - """A superadmin caller sees both library and course scope assignments. + # All users invalid + (["nonexistent1", "nonexistent2"], [], ["nonexistent1", "nonexistent2"]), + # All invalid emails + (["fake1@example.com", "fake2@example.com"], [], ["fake1@example.com", "fake2@example.com"]), + # Single invalid user + (["nonexistent_user"], [], ["nonexistent_user"]), + # Single invalid email + (["fake@example.com"], [], ["fake@example.com"]), + ) + @unpack + def test_post_all_users_invalid(self, input_users: list, expected_valid: list, expected_invalid: list): + """Test user validation when all users are invalid. Expected result: - - Response includes assignments with both lib: and course-v1: scope prefixes. + - Returns 200 OK status + - valid_users list is empty + - All users are in invalid_users list + - Summary contains correct counts """ - response = self.client.get(self.url, {"page_size": 100}) - + self.client.force_authenticate(user=self.admin_user) + request_data = {"users": input_users} + response = self.client.post(self.url, data=request_data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) - non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] - scopes = {item["scope"] for item in non_superadmin_items} - lib_scopes = {s for s in scopes if s.startswith("lib:")} - course_scopes = {s for s in scopes if s.startswith("course-v1:")} - self.assertGreater(len(lib_scopes), 0) - self.assertGreater(len(course_scopes), 0) - - # ------------------------------------------------------------------ # - # No permissions at all # - # ------------------------------------------------------------------ # + self.assertEqual(response.data["valid_users"], expected_valid) + self.assertEqual(set(response.data["invalid_users"]), set(expected_invalid)) + self.assertEqual(response.data["summary"]["total"], len(input_users)) + self.assertEqual(response.data["summary"]["valid_count"], len(expected_valid)) + self.assertEqual(response.data["summary"]["invalid_count"], len(expected_invalid)) - def test_user_without_any_permissions_gets_403(self): - """A user with no role assignments at all is rejected by AnyScopePermission. + @data( + # Missing users field + {}, + {"other_field": "value"}, + # Empty users list (not allowed by serializer) + {"users": []}, + # Invalid data types + {"users": "not_a_list"}, + {"users": [{"not": "string"}]}, + # Null values + {"users": None}, + {"users": [None, "admin_1"]}, + # Users with strings too long (over 255 characters) + {"users": ["a" * 256]}, + ) + def test_post_invalid_request_data(self, request_data: dict): + """Test user validation with invalid request data. - AnyScopePermission requires the user to have at least one of - VIEW_LIBRARY_TEAM or COURSES_VIEW_COURSE_TEAM in any scope. - A user with no assignments has neither, so they get 403. + Test cases: + - Missing required fields + - Empty users list (not allowed) + - Invalid data types + - Null values + - Strings exceeding max length Expected result: - - Returns 403 FORBIDDEN. + - Returns 400 BAD REQUEST status """ - no_perms_user, _ = User.objects.get_or_create( - username="no_perms_user", - defaults={"email": "no_perms@example.com"}, - ) - self.client.force_authenticate(user=no_perms_user) - - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - # ------------------------------------------------------------------ # - # Scoped permissions: courses only # - # ------------------------------------------------------------------ # - - def test_user_with_course_scope_permission_sees_course_assignments(self): - """A user with COURSES_VIEW_COURSE_TEAM on a specific course sees those assignments. + self.client.force_authenticate(user=self.admin_user) + response = self.client.post(self.url, data=request_data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - regular_9 has course_staff in course-v1:Org1+COURSE1+2024. - course_staff includes COURSES_VIEW_COURSE_TEAM. + @data( + # Unauthenticated request + (None, status.HTTP_401_UNAUTHORIZED), + # Admin user with proper permissions (superuser) + ("admin_1", status.HTTP_200_OK), + # Regular user without required permissions (only LIBRARY_USER) + ("regular_1", status.HTTP_403_FORBIDDEN), + # Regular user with LIBRARY_ADMIN role (has MANAGE_LIBRARY_TEAM permission) + ("regular_5", status.HTTP_200_OK), + ) + @unpack + def test_post_authentication_and_permissions(self, username: str, expected_status: int): + """Test user validation with different authentication and permission scenarios. Expected result: - - Sees the 2 course assignments in course-v1:Org1+COURSE1+2024 (no superadmin entries). - - Does NOT see any library assignments (no VIEW_LIBRARY_TEAM). + - Returns 401 UNAUTHORIZED for unauthenticated requests + - Returns 403 FORBIDDEN for authenticated users without permissions + - Returns 200 OK for users with proper permissions """ - user = User.objects.get(username="regular_9") - self.client.force_authenticate(user=user) - - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 2) - - non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] - # All non-superadmin items should be course assignments - for item in non_superadmin_items: - self.assertTrue(item["scope"].startswith("course-v1:"), f"Expected course scope, got {item['scope']}") + if username: + user = User.objects.get(username=username) + self.client.force_authenticate(user=user) + else: + self.client.force_authenticate(user=None) + request_data = {"users": ["admin_1", "regular_1"]} + response = self.client.post(self.url, data=request_data, format="json") + self.assertEqual(response.status_code, expected_status) + if expected_status == status.HTTP_200_OK: + self.assertIn("valid_users", response.data) + self.assertIn("invalid_users", response.data) + self.assertIn("summary", response.data) + self.assertIn("total", response.data["summary"]) + self.assertIn("valid_count", response.data["summary"]) + self.assertIn("invalid_count", response.data["summary"]) - def test_user_with_course_scope_permission_does_not_see_library_assignments(self): - """A user with only course permissions cannot see library assignments. + def test_post_serializer_deduplication(self): + """Test that serializer properly deduplicates users while preserving order. - regular_9 has course_staff in course-v1:Org1+COURSE1+2024 but no library roles. + The serializer automatically removes duplicates using dict.fromkeys(). Expected result: - - No library-scope assignments appear in the results. + - Returns 200 OK status + - Duplicates are automatically removed by the serializer + - Order is preserved for first occurrence """ - user = User.objects.get(username="regular_9") - self.client.force_authenticate(user=user) - - response = self.client.get(self.url) - + self.client.force_authenticate(user=self.admin_user) + request_data = {"users": ["admin_1", "admin_1", "nonexistent", "nonexistent", "regular_1"]} + response = self.client.post(self.url, data=request_data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) - non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] - lib_items = [item for item in non_superadmin_items if item["scope"].startswith("lib:")] - self.assertEqual(len(lib_items), 0) - - # ------------------------------------------------------------------ # - # Scoped permissions: libraries only # - # ------------------------------------------------------------------ # - - def test_user_with_library_scope_permission_sees_library_assignments(self): - """A user with VIEW_LIBRARY_TEAM on a specific library sees those assignments. + self.assertEqual(response.data["valid_users"], ["admin_1", "regular_1"]) + self.assertEqual(response.data["invalid_users"], ["nonexistent"]) + self.assertEqual(response.data["summary"]["total"], 3) + self.assertEqual(response.data["summary"]["valid_count"], 2) + self.assertEqual(response.data["summary"]["invalid_count"], 1) - regular_1 has library_user in lib:Org1:LIB1. - library_user includes VIEW_LIBRARY_TEAM. + def test_post_large_user_list(self): + """Test user validation with a large list of users. Expected result: - - Sees the 3 library assignments in lib:Org1:LIB1 (no superadmin entries). - - Does NOT see any course assignments (no COURSES_VIEW_COURSE_TEAM). + - Returns 200 OK status + - Correctly processes all users in the list + - Response structure is maintained """ - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - - response = self.client.get(self.url) - + self.client.force_authenticate(user=self.admin_user) + valid_users = ["admin_1", "admin_2", "regular_1", "regular_2"] + invalid_users = [f"nonexistent_{i}" for i in range(10)] + all_users = valid_users + invalid_users + request_data = {"users": all_users} + response = self.client.post(self.url, data=request_data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 3) - - non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] - # All non-superadmin items should be library assignments - for item in non_superadmin_items: - self.assertTrue(item["scope"].startswith("lib:"), f"Expected library scope, got {item['scope']}") - - def test_user_with_library_scope_permission_does_not_see_course_assignments(self): - """A user with only library permissions cannot see course assignments. + self.assertEqual(set(response.data["valid_users"]), set(valid_users)) + self.assertEqual(set(response.data["invalid_users"]), set(invalid_users)) + self.assertEqual(response.data["summary"]["total"], len(all_users)) + self.assertEqual(response.data["summary"]["valid_count"], len(valid_users)) + self.assertEqual(response.data["summary"]["invalid_count"], len(invalid_users)) - regular_1 has library_user in lib:Org1:LIB1 but no course roles. + def test_post_response_serializer_structure(self): + """Test that response matches UserValidationAPIViewResponseSerializer structure. Expected result: - - No course-scope assignments appear in the results. + - Returns 200 OK status + - Response contains all required fields + - Field types match serializer definition """ - user = User.objects.get(username="regular_1") - self.client.force_authenticate(user=user) - - response = self.client.get(self.url) - + self.client.force_authenticate(user=self.admin_user) + request_data = {"users": ["admin_1", "nonexistent"]} + response = self.client.post(self.url, data=request_data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) - non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] - course_items = [item for item in non_superadmin_items if item["scope"].startswith("course-v1:")] - self.assertEqual(len(course_items), 0) - - # ------------------------------------------------------------------ # - # Org-level permissions: courses # - # ------------------------------------------------------------------ # - - def test_user_with_org_course_permission_sees_org_course_assignments(self): - """A user with course_staff at org level sees all course assignments in that org. + required_fields = ["valid_users", "invalid_users", "summary"] + for field in required_fields: + self.assertIn(field, response.data) + summary_fields = ["total", "valid_count", "invalid_count"] + for field in summary_fields: + self.assertIn(field, response.data["summary"]) + self.assertIsInstance(response.data["summary"][field], int) + self.assertIsInstance(response.data["valid_users"], list) + self.assertIsInstance(response.data["invalid_users"], list) - Assign regular_10 course_staff at org-level glob course-v1:Org1+* so they - can see all course assignments in Org1. + def test_post_inactive_user_validation(self): + """Test that inactive users are returned as invalid. Expected result: - - Sees course assignments in Org1. - - Does NOT see library assignments. + - Inactive users appear in invalid_users list + - Summary counts reflect inactive users as invalid + - Active users appear in valid_users list """ - self._assign_roles_to_users( - [ - { - "subject_name": "regular_10", - "role_name": roles.COURSE_STAFF.external_key, - "scope_name": COURSE_ORG1_GLOB, - }, - ] - ) - user = User.objects.get(username="regular_10") - self.client.force_authenticate(user=user) - - response = self.client.get(self.url) - + User.objects.create(username="inactive_user", email="inactive@example.com", is_active=False) + self.client.force_authenticate(user=self.admin_user) + request_data = {"users": ["inactive_user", "inactive@example.com", "admin_1"]} + response = self.client.post(self.url, data=request_data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) - non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] - # All non-superadmin items should be course assignments - for item in non_superadmin_items: - self.assertTrue(item["scope"].startswith("course-v1:"), f"Expected course scope, got {item['scope']}") - # Should not see any library assignments - lib_items = [item for item in non_superadmin_items if item["scope"].startswith("lib:")] - self.assertEqual(len(lib_items), 0) - - # ------------------------------------------------------------------ # - # Org-level permissions: libraries # - # ------------------------------------------------------------------ # + self.assertIn("inactive_user", response.data["invalid_users"]) + self.assertIn("inactive@example.com", response.data["invalid_users"]) + self.assertIn("admin_1", response.data["valid_users"]) + self.assertEqual(response.data["summary"]["total"], 3) + self.assertEqual(response.data["summary"]["valid_count"], 1) + self.assertEqual(response.data["summary"]["invalid_count"], 2) - def test_user_with_org_library_permission_sees_org_library_assignments(self): - """A user with library_user at org level sees all library assignments in that org. + def test_post_with_validate_users_exception(self): + """Test handling of unexpected exceptions from validate_users.""" + self.client.force_authenticate(user=self.admin_user) + with patch.object(api, "validate_users") as mock_validate_users: + mock_validate_users.side_effect = Exception("Database connection error") + request_data = {"users": ["admin_1"]} + response = self.client.post(self.url, data=request_data, format="json") + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertEqual(response.data["message"], "An error occurred while validating users") - Assign regular_9 library_user at org-level glob lib:Org1:* so they - can see all library assignments in Org1, in addition to their existing - course assignments. + def test_post_global_permission_inheritance(self): + """Test that UserValidationAPIView properly inherits from AnyScopePermission class.""" + self.assertIn(AnyScopePermission, UserValidationAPIView.permission_classes) - Expected result: - - Sees library assignments in Org1 + course assignments + superadmin entries. - """ - self._assign_roles_to_users( - [ - { - "subject_name": "regular_9", - "role_name": roles.LIBRARY_USER.external_key, - "scope_name": "lib:Org1:*", - }, - ] + def test_post_multiple_roles_user_access(self): + """Test access for a user with multiple roles that include management permissions.""" + test_user = User.objects.create(username="multi_role_user", email="multi@example.com") + assign_role_to_user_in_scope( + user_external_key="multi_role_user", + role_external_key=roles.LIBRARY_ADMIN.external_key, + scope_external_key="lib:Org1:LIB1", ) - user = User.objects.get(username="regular_9") - self.client.force_authenticate(user=user) - - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] - lib_items = [item for item in non_superadmin_items if item["scope"].startswith("lib:")] - course_items = [item for item in non_superadmin_items if item["scope"].startswith("course-v1:")] - # Should see library assignments in Org1 (3 assignments) - self.assertGreater(len(lib_items), 0) - for item in lib_items: - self.assertEqual(item["org"], "Org1") - # Should also see course assignments (from their existing course_staff role) - self.assertGreater(len(course_items), 0) - - def test_user_with_org_library_permission_does_not_see_other_org_libraries(self): - """A user with org-level library permission only sees that org's library assignments. - - Assign regular_9 library_user at org-level glob lib:Org1:* — they should - NOT see Org2 or Org3 library assignments. - - Expected result: - - Library assignments are limited to Org1. - """ - self._assign_roles_to_users( - [ - { - "subject_name": "regular_9", - "role_name": roles.LIBRARY_USER.external_key, - "scope_name": "lib:Org1:*", - }, - ] + assign_role_to_user_in_scope( + user_external_key="multi_role_user", + role_external_key=roles.LIBRARY_USER.external_key, + scope_external_key="lib:Org2:LIB2", ) - user = User.objects.get(username="regular_9") - self.client.force_authenticate(user=user) - - response = self.client.get(self.url) - + self.client.force_authenticate(user=test_user) + request_data = {"users": ["admin_1", "regular_1"]} + response = self.client.post(self.url, data=request_data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) - non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] - lib_items = [item for item in non_superadmin_items if item["scope"].startswith("lib:")] - lib_orgs = {item["org"] for item in lib_items} - self.assertEqual(lib_orgs, {"Org1"}) - - # ------------------------------------------------------------------ # - # Mixed permissions: both library and course # - # ------------------------------------------------------------------ # - - def test_user_with_both_library_and_course_permissions(self): - """A user with permissions in both library and course scopes sees both. - - Assign regular_9 library_user at lib:Org1:* (in addition to their existing - course_staff at course-v1:Org1+COURSE1+2024). - - Expected result: - - Sees both library and course assignments + superadmin entries. - """ - self._assign_roles_to_users( - [ - { - "subject_name": "regular_9", - "role_name": roles.LIBRARY_USER.external_key, - "scope_name": "lib:Org1:*", - }, - ] - ) - user = User.objects.get(username="regular_9") - self.client.force_authenticate(user=user) - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - non_superadmin_items = [item for item in response.data["results"] if not item["is_superadmin"]] - scope_types = {item["scope"].split(":")[0] for item in non_superadmin_items} - self.assertIn("lib", scope_types) - self.assertIn("course-v1", scope_types) + def test_post_empty_role_assignments_denied(self): + """Test that a user with no role assignments is properly denied access.""" + test_user = User.objects.create(username="no_roles_user", email="noroles@example.com") + self.client.force_authenticate(user=test_user) + request_data = {"users": ["admin_1", "regular_1"]} + response = self.client.post(self.url, data=request_data, format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @ddt @@ -4338,54 +1858,3 @@ def test_scope_permission_vs_platform_permission(self, username, scopes, expecte response = self._put_lib(scopes, roles.LIBRARY_ADMIN.external_key) self.assertEqual(response.status_code, expected_status) - - -class TestWaffleFlagStatesAPIView(ViewTestMixin): - """Test suite for WaffleFlagStatesAPIView.""" - - def setUp(self): - """Set up test fixtures.""" - super().setUp() - self.url = reverse("openedx_authz:waffle-flag-states") - - def test_get_returns_the_waffle_flag_states(self): - """Test GET /waffle-flag-states/ with a successful lookup. - - Expected result: - - Returns 200 OK status. - - The response body is whatever get_waffle_flag_states returns, unchanged. - """ - flag_states = { - "global": True, - "org_overrides": {"on": ["Org1"], "off": []}, - "course_overrides": {"on": [], "off": ["course-v1:Org1+COURSE1+2024"]}, - } - with patch("openedx_authz.rest_api.v1.views.get_waffle_flag_states", return_value=flag_states): - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, flag_states) - - def test_get_handles_an_unexpected_error(self): - """Test GET /waffle-flag-states/ when get_waffle_flag_states raises. - - Expected result: - - Returns 500 INTERNAL SERVER ERROR status with a generic error message. - """ - with patch("openedx_authz.rest_api.v1.views.get_waffle_flag_states", side_effect=Exception("boom")): - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) - self.assertEqual(response.data, {"message": "error"}) - - def test_get_requires_authentication(self): - """Test GET /waffle-flag-states/ without authentication. - - Expected result: - - Returns 401 UNAUTHORIZED status. - """ - self.client.force_authenticate(user=None) - - response = self.client.get(self.url) - - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/openedx_authz/tests/test_handlers.py b/openedx_authz/tests/test_handlers.py index 88c5be99..93c7bf0c 100644 --- a/openedx_authz/tests/test_handlers.py +++ b/openedx_authz/tests/test_handlers.py @@ -316,7 +316,7 @@ def test_skips_when_waffle_flag_name_mismatch(self, sender_model, instance_kwarg @patch("openedx_authz.handlers.logger") def test_skips_when_automatic_migration_setting_disabled( self, sender_model, instance_kwargs, scope_key, mock_logger, mock_run - ): # pylint: disable=too-many-positional-arguments + ): """When the setting is off, the handler returns before scheduling work (course and org).""" instance = sender_model(**instance_kwargs) @@ -345,7 +345,7 @@ def test_logs_error_for_unsupported_instance_type(self, mock_logger, mock_run): @patch("openedx_authz.handlers.logger") def test_course_scope_migration_depends_on_override_choice( self, override_choice, expected_migration_type, expect_migration, mock_logger, mock_run - ): # pylint: disable=too-many-positional-arguments + ): """Course override runs forward only when forced on, force-off is a no-op for migration.""" instance = WaffleFlagCourseOverrideModel.objects.create( course_id=self.COURSE_KEY, @@ -380,7 +380,7 @@ def test_course_scope_migration_depends_on_override_choice( @patch("openedx_authz.handlers.logger") def test_org_scope_migration_depends_on_override_choice( self, override_choice, expected_migration_type, expect_migration, mock_logger, mock_run - ): # pylint: disable=too-many-positional-arguments + ): """Org override runs forward only when forced on, force-off is a no-op for migration.""" instance = WaffleFlagOrgOverrideModel.objects.create( org=self.ORG_KEY, @@ -462,7 +462,7 @@ def test_org_scope_passes_excluded_course_ids_when_course_overrides_oppose_org(s @patch("openedx_authz.handlers.logger") def test_skips_when_previous_enabled_record_has_same_override_choice( self, sender_model, row_kwargs, scope_key, mock_logger, mock_run - ): # pylint: disable=too-many-positional-arguments + ): """Repeated history rows with the same active override choice do not trigger migration.""" sender_model.objects.create(**row_kwargs) instance = sender_model.objects.create(**row_kwargs) @@ -509,7 +509,7 @@ def test_skips_when_previous_enabled_record_has_same_override_choice( @unpack def test_runs_when_previous_record_disabled_even_if_same_override_choice( self, sender_model, prev_kwargs, instance_kwargs, scope_key, mock_run - ): # pylint: disable=too-many-positional-arguments + ): """If the prior row was inactive, a new active row still triggers migration (course and org).""" sender_model.objects.create(**prev_kwargs) instance = sender_model.objects.create(**instance_kwargs)