diff --git a/conftest.py b/conftest.py index a748689056..1d51b7705a 100644 --- a/conftest.py +++ b/conftest.py @@ -2,6 +2,7 @@ import uuid from pathlib import Path +from types import SimpleNamespace import pytest from faker import Faker @@ -27,6 +28,7 @@ def default_settings(monkeypatch, settings): settings.FEATURES[features.IGNORE_EDX_FAILURES] = False settings.FEATURES[features.SYNC_ON_DASHBOARD_LOAD] = False settings.FEATURES[features.ENABLE_PROGRAM_SPECIFIC_PATHWAY_SCHOOLS] = False + settings.FEATURES[features.EXPORT_COMPLIANCE_CHECK_ENABLED] = True @pytest.fixture(autouse=True) @@ -65,6 +67,20 @@ def export_compliance_keypair(settings): return private_key +@pytest.fixture(autouse=True) +def mocked_export_compliance(mocker): + """Mock export compliance checks in shared enrollment helpers by default.""" + return mocker.patch( + "courses.api.verify_user_with_exports", + return_value=SimpleNamespace( + accepted=True, + decision="ACCEPT", + reason_code=100, + request_id="test-request-id", + ), + ) + + @pytest.fixture(autouse=True) def mock_hubspot_api(mocker): """Mock the Hubspot CRM API""" diff --git a/courses/api.py b/courses/api.py index eea610d2f8..950544344f 100644 --- a/courses/api.py +++ b/courses/api.py @@ -32,6 +32,8 @@ from b2b.api import process_add_org_membership from cms.api import create_default_courseware_page +from compliance.api import verify_user_with_exports +from compliance.exceptions import ExportComplianceError from courses import mail_api from courses.constants import ( COURSE_KEY_PATTERN, @@ -205,6 +207,8 @@ def create_run_enrollments( # noqa: C901 created in mitxonline, paired with a boolean indicating whether or not the edX enrollment API call was successful for all of the given course runs """ + _verify_exports_compliance_for_enrollment(user, runs[0]) + if keep_failed_enrollments is None: keep_failed_enrollments = settings.FEATURES.get( features.IGNORE_EDX_FAILURES, False @@ -323,6 +327,8 @@ def create_program_enrollments( """ successful_enrollments = [] for program in programs: + _verify_exports_compliance_for_enrollment(user, program) + try: enrollment, created = ProgramEnrollment.all_objects.get_or_create( user=user, @@ -339,7 +345,7 @@ def create_program_enrollments( if not created and enrollment.enrollment_mode != enrollment_mode: enrollment.update_mode_and_save(enrollment_mode) - except: # pylint: disable=bare-except # noqa: PERF203, E722 + except: # pylint: disable=bare-except # noqa: E722 mail_api.send_enrollment_failure_message( user, program, details=format_exc() ) @@ -399,6 +405,25 @@ def upgrade_audit_run_enrollments_for_program_purchase(user, program): return upgraded_enrollments +def _verify_exports_compliance_for_enrollment(user, courseware_object) -> None: + """Verify users with CyberSource before creating enrollments.""" + if not settings.FEATURES.get(features.EXPORT_COMPLIANCE_CHECK_ENABLED, False): + return + + result = verify_user_with_exports(user, courseware_object) + if result.accepted: + return + + log.warning( + "Export compliance check did not accept enrollment for user=%s: " + "decision=%r, reason_code=%r", + user.id, + result.decision, + result.reason_code, + ) + raise ExportComplianceError(user, result.decision, result.reason_code) + + def downgrade_learner(enrollment): """ Downgrades given enrollment from verified to audit. diff --git a/courses/api_test.py b/courses/api_test.py index b74eb29350..8b5ed4e0a3 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -35,6 +35,8 @@ OrganizationPageFactory, ) from cms.factories import CourseIndexPageFactory +from compliance.api import ExportComplianceResult +from compliance.exceptions import ExportComplianceError from courses.api import ( check_course_modes, create_local_enrollment, @@ -863,6 +865,184 @@ def test_mixed_enrollments_upgrades_only_audit( assert verified_enrollment.enrollment_mode == EDX_ENROLLMENT_VERIFIED_MODE +def test_create_run_enrollments_verifies_exports_for_verified_mode( + mocker, user, django_capture_on_commit_callbacks +): + """Verified course enrollments should require an accepted export check.""" + run = CourseRunFactory.create() + patched_verify = mocker.patch( + "courses.api.verify_user_with_exports", + return_value=ExportComplianceResult( + decision="COMPLETED", + reason_code=100, + request_id="req-123", + raw={}, + ), + ) + patched_edx_enroll = mocker.patch("courses.api.enroll_in_edx_course_runs") + mocker.patch("courses.api.mail_api.send_course_run_enrollment_email") + mocker.patch("courses.tasks.subscribe_edx_course_emails.delay") + + with django_capture_on_commit_callbacks(execute=True): + successful_enrollments, edx_request_success = create_run_enrollments( + user, [run], mode=EDX_ENROLLMENT_VERIFIED_MODE + ) + + patched_verify.assert_called_once_with(user, run) + patched_edx_enroll.assert_called_once_with( + user, + [run], + mode=EDX_ENROLLMENT_VERIFIED_MODE, + ) + assert edx_request_success is True + assert len(successful_enrollments) == 1 + + +def test_create_run_enrollments_verifies_exports_for_audit_mode(mocker, user): + """Audit course enrollments should also require an accepted export check.""" + run = CourseRunFactory.create() + patched_verify = mocker.patch( + "courses.api.verify_user_with_exports", + return_value=ExportComplianceResult( + decision="COMPLETED", + reason_code=100, + request_id="req-123", + raw={}, + ), + ) + patched_edx_enroll = mocker.patch("courses.api.enroll_in_edx_course_runs") + mocker.patch("courses.api.mail_api.send_course_run_enrollment_email") + mocker.patch("courses.tasks.subscribe_edx_course_emails.delay") + + create_run_enrollments(user, [run], mode=EDX_ENROLLMENT_AUDIT_MODE) + + patched_verify.assert_called_once_with(user, run) + patched_edx_enroll.assert_called_once() + + +def test_create_run_enrollments_rejects_nonaccepted_exports(mocker, user): + """Verified course enrollments should fail closed when exports are not accepted.""" + run = CourseRunFactory.create() + patched_verify = mocker.patch( + "courses.api.verify_user_with_exports", + return_value=ExportComplianceResult( + decision="REJECT", + reason_code=102, + request_id="req-123", + raw={}, + ), + ) + patched_edx_enroll = mocker.patch("courses.api.enroll_in_edx_course_runs") + + with pytest.raises( + ExportComplianceError, match="Export compliance check did not accept" + ): + create_run_enrollments(user, [run], mode=EDX_ENROLLMENT_VERIFIED_MODE) + + patched_verify.assert_called_once_with(user, run) + patched_edx_enroll.assert_not_called() + assert not CourseRunEnrollment.objects.filter(user=user, run=run).exists() + + +def test_create_run_enrollments_skips_exports_check_when_feature_disabled( + settings, mocker, user +): + """The export compliance check should be skipped entirely when the feature flag is off.""" + settings.FEATURES[features.EXPORT_COMPLIANCE_CHECK_ENABLED] = False + run = CourseRunFactory.create() + patched_verify = mocker.patch( + "courses.api.verify_user_with_exports", + return_value=ExportComplianceResult( + decision="REJECT", + reason_code=102, + request_id="req-123", + raw={}, + ), + ) + patched_edx_enroll = mocker.patch("courses.api.enroll_in_edx_course_runs") + + successful_enrollments, _ = create_run_enrollments( + user, [run], mode=EDX_ENROLLMENT_VERIFIED_MODE + ) + + patched_verify.assert_not_called() + patched_edx_enroll.assert_called_once() + assert len(successful_enrollments) == 1 + + +def test_create_program_enrollments_verifies_exports_for_verified_mode(mocker, user): + """Verified program enrollments should require an accepted export check.""" + program = ProgramFactory.create() + patched_verify = mocker.patch( + "courses.api.verify_user_with_exports", + return_value=ExportComplianceResult( + decision="COMPLETED", + reason_code=100, + request_id="req-123", + raw={}, + ), + ) + + successful_enrollments = create_program_enrollments( + user, + [program], + enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, + ) + + patched_verify.assert_called_once_with(user, program) + assert len(successful_enrollments) == 1 + assert successful_enrollments[0].program == program + + +def test_create_program_enrollments_verifies_exports_for_default_mode(mocker, user): + """Default program enrollments should also require an accepted export check.""" + program = ProgramFactory.create() + patched_verify = mocker.patch( + "courses.api.verify_user_with_exports", + return_value=ExportComplianceResult( + decision="COMPLETED", + reason_code=100, + request_id="req-123", + raw={}, + ), + ) + + successful_enrollments = create_program_enrollments( + user, + [program], + ) + + patched_verify.assert_called_once_with(user, program) + assert len(successful_enrollments) == 1 + assert successful_enrollments[0].program == program + + +def test_create_program_enrollments_rejects_nonaccepted_exports(mocker, user): + """Verified program enrollments should fail closed when exports are not accepted.""" + program = ProgramFactory.create() + patched_verify = mocker.patch( + "courses.api.verify_user_with_exports", + return_value=ExportComplianceResult( + decision="REVIEW", + reason_code=480, + request_id="req-123", + raw={}, + ), + ) + + with pytest.raises( + ExportComplianceError, match="Export compliance check did not accept" + ): + create_program_enrollments( + user, + [program], + enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, + ) + + patched_verify.assert_called_once_with(user, program) + assert not ProgramEnrollment.objects.filter(user=user, program=program).exists() + + class TestDeactivateEnrollments: """Test cases for functions that deactivate enrollments""" diff --git a/courses/serializers/v1/courses.py b/courses/serializers/v1/courses.py index b07b8305fc..3ea513a98c 100644 --- a/courses/serializers/v1/courses.py +++ b/courses/serializers/v1/courses.py @@ -10,6 +10,7 @@ from rest_framework.exceptions import ValidationError from cms.serializers import CoursePageSerializer +from compliance.exceptions import ExportComplianceCheckError from courses import models from courses.api import create_run_enrollments from courses.serializers.v1.base import ( @@ -175,13 +176,16 @@ def create(self, validated_data): if run.b2b_contract is not None: raise ValidationError({"run_id": f"Invalid course run id: {run_id}"}) - successful_enrollments, _ = create_run_enrollments( - user, - [run], - keep_failed_enrollments=settings.FEATURES.get( - features.IGNORE_EDX_FAILURES, False - ), - ) + try: + successful_enrollments, _ = create_run_enrollments( + user, + [run], + keep_failed_enrollments=settings.FEATURES.get( + features.IGNORE_EDX_FAILURES, False + ), + ) + except ExportComplianceCheckError as exc: + raise ValidationError(exc.to_error_detail()) from exc return successful_enrollments[0] if successful_enrollments else None diff --git a/courses/serializers/v2/courses.py b/courses/serializers/v2/courses.py index c6bf688fcf..e43a465a69 100644 --- a/courses/serializers/v2/courses.py +++ b/courses/serializers/v2/courses.py @@ -12,6 +12,7 @@ from rest_framework.exceptions import ValidationError from cms.serializers import CoursePageSerializer +from compliance.exceptions import ExportComplianceCheckError from courses import models from courses.api import create_run_enrollments from courses.serializers.utils import get_topics_from_page @@ -340,13 +341,18 @@ def create(self, validated_data): if run.b2b_contract is not None: raise ValidationError({"run_id": f"Invalid course run id: {run_id}"}) - successful_enrollments, _ = create_run_enrollments( - user, - [run], - keep_failed_enrollments=settings.FEATURES.get( - features.IGNORE_EDX_FAILURES, False - ), - ) + + try: + successful_enrollments, _ = create_run_enrollments( + user, + [run], + keep_failed_enrollments=settings.FEATURES.get( + features.IGNORE_EDX_FAILURES, False + ), + ) + except ExportComplianceCheckError as exc: + raise ValidationError(exc.to_error_detail()) from exc + return successful_enrollments[0] if successful_enrollments else None @extend_schema_field(serializers.IntegerField(allow_null=True)) diff --git a/courses/serializers/v3/courses.py b/courses/serializers/v3/courses.py index cf6a32e761..ff38929fb7 100644 --- a/courses/serializers/v3/courses.py +++ b/courses/serializers/v3/courses.py @@ -9,6 +9,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError +from compliance.exceptions import ExportComplianceCheckError from courses import models from courses.api import create_run_enrollments from courses.serializers.v1.base import ( @@ -113,13 +114,17 @@ def create(self, validated_data): if run is None or run.b2b_contract_id is not None: raise ValidationError({"run_id": f"Invalid course run id: {run_id}"}) - successful_enrollments, _ = create_run_enrollments( - user, - [run], - keep_failed_enrollments=settings.FEATURES.get( - features.IGNORE_EDX_FAILURES, False - ), - ) + try: + successful_enrollments, _ = create_run_enrollments( + user, + [run], + keep_failed_enrollments=settings.FEATURES.get( + features.IGNORE_EDX_FAILURES, False + ), + ) + except ExportComplianceCheckError as exc: + raise ValidationError(exc.to_error_detail()) from exc + if not successful_enrollments: msg = "Unable to create course run enrollment" raise ValueError(msg) diff --git a/courses/views/v1/__init__.py b/courses/views/v1/__init__.py index 20148b91a3..6fc8ac9ee7 100644 --- a/courses/views/v1/__init__.py +++ b/courses/views/v1/__init__.py @@ -28,6 +28,7 @@ from rest_framework.views import APIView from reversion.models import Version +from compliance.exceptions import ExportComplianceCheckError from courses.api import ( create_run_enrollments, deactivate_run_enrollment, @@ -368,13 +369,19 @@ def post(self, request): create_user(user) user.refresh_from_db() - _, edx_request_success = create_run_enrollments( - user=user, - runs=[run], - keep_failed_enrollments=settings.FEATURES.get( - features.IGNORE_EDX_FAILURES, False - ), - ) + try: + _, edx_request_success = create_run_enrollments( + user=user, + runs=[run], + keep_failed_enrollments=settings.FEATURES.get( + features.IGNORE_EDX_FAILURES, False + ), + ) + except ExportComplianceCheckError as exc: + return redirect_with_user_message( + reverse("user-dashboard"), + {"type": USER_MSG_TYPE_ENROLL_BLOCKED, **exc.to_error_detail()}, + ) def respond(data, status=True): # noqa: FBT002 """ diff --git a/courses/views/v1/views_test.py b/courses/views/v1/views_test.py index 08c97fbb2e..8593c5789e 100644 --- a/courses/views/v1/views_test.py +++ b/courses/views/v1/views_test.py @@ -24,6 +24,7 @@ from b2b.factories import ContractPageFactory from cms.factories import ProgramPageFactory from cms.serializers import CoursePageSerializer, ProgramPageSerializer +from compliance.exceptions import ExportComplianceDataError, ExportComplianceError from courses.constants import ENROLL_CHANGE_STATUS_UNENROLLED from courses.factories import ( BlockedCountryFactory, @@ -541,6 +542,24 @@ def test_user_enrollments_create_invalid(user_drf_client, user): assert resp.json() == {"errors": {"run_id": "Invalid course run id: 1234"}} +def test_user_enrollments_create_export_compliance_blocked( + mocker, user_drf_client, user +): + """The user enrollments view should fail closed when the export compliance check rejects the user.""" + run = CourseRunFactory.create() + exc = ExportComplianceError(user, "REJECT", "102") + mocker.patch( + "courses.serializers.v1.courses.create_run_enrollments", + side_effect=exc, + ) + resp = user_drf_client.post( + reverse("v1:user-enrollments-api-list"), data={"run_id": run.id} + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert resp.json() == {"errors": exc.to_error_detail()} + assert not CourseRunEnrollment.objects.filter(user=user, run=run).exists() + + def test_user_enrollments_create_b2b_run_invalid(user_drf_client, user): """Creating an enrollment for a B2B course run via the public API should be rejected.""" contract = ContractPageFactory.create() @@ -737,6 +756,54 @@ def test_create_enrollments_blocked_country(user_client, user): ) +def test_create_enrollments_export_compliance_blocked(mocker, user_client, user): + """ + Create enrollment view should redirect with a user message in a cookie + if the export compliance check does not accept the enrollment. + """ + run = CourseRunFactory.create() + exc = ExportComplianceError(user, "REJECT", "102") + mocker.patch( + "courses.views.v1.create_run_enrollments", + side_effect=exc, + ) + resp = user_client.post( + reverse("create-enrollment-via-form"), + data={"run": str(run.id)}, + HTTP_REFERER=EXAMPLE_URL, + ) + assert resp.status_code == status.HTTP_302_FOUND + assert resp.url == reverse("user-dashboard") + assert USER_MSG_COOKIE_NAME in resp.cookies + assert resp.cookies[USER_MSG_COOKIE_NAME].value == encode_json_cookie_value( + {"type": USER_MSG_TYPE_ENROLL_BLOCKED, **exc.to_error_detail()} + ) + assert not CourseRunEnrollment.objects.filter(user=user, run=run).exists() + + +def test_create_enrollments_export_compliance_missing_data(mocker, user_client, user): + """ + Create enrollment view should redirect with the missing profile fields + when the export compliance check can't run due to incomplete profile data. + """ + run = CourseRunFactory.create() + exc = ExportComplianceDataError(user, ["postal_code", "state"]) + mocker.patch( + "courses.views.v1.create_run_enrollments", + side_effect=exc, + ) + resp = user_client.post( + reverse("create-enrollment-via-form"), + data={"run": str(run.id)}, + HTTP_REFERER=EXAMPLE_URL, + ) + assert resp.status_code == status.HTTP_302_FOUND + assert resp.url == reverse("user-dashboard") + assert resp.cookies[USER_MSG_COOKIE_NAME].value == encode_json_cookie_value( + {"type": USER_MSG_TYPE_ENROLL_BLOCKED, **exc.to_error_detail()} + ) + + @pytest.mark.parametrize("receive_emails", [True, False]) def test_update_user_enrollment(mocker, user_drf_client, user, receive_emails): """The enrollment should update the course email subscriptions""" diff --git a/courses/views/v2/__init__.py b/courses/views/v2/__init__.py index 24b494c953..37963c9b86 100644 --- a/courses/views/v2/__init__.py +++ b/courses/views/v2/__init__.py @@ -32,6 +32,7 @@ from rest_framework.response import Response from cms.models import CoursePage +from compliance.exceptions import ExportComplianceCheckError from courses.api import ( create_program_enrollments, create_run_enrollments, @@ -784,12 +785,15 @@ def _create_course_enrollment_from_program(request, courserun_id, program_enroll if should_create_audit_enrollment: # Audit enrollments just get created, regardless of whether or not # the course is an elective. - enrollments, _ = create_run_enrollments( - request.user, - [run], - mode=EDX_ENROLLMENT_AUDIT_MODE, - keep_failed_enrollments=True, - ) + try: + enrollments, _ = create_run_enrollments( + request.user, + [run], + mode=EDX_ENROLLMENT_AUDIT_MODE, + keep_failed_enrollments=True, + ) + except ExportComplianceCheckError as exc: + return Response(exc.to_error_detail(), status=status.HTTP_400_BAD_REQUEST) if len(enrollments) == 0: raise EnrollmentCreationFailedError return Response( @@ -810,6 +814,51 @@ def _create_course_enrollment_from_program(request, courserun_id, program_enroll ) +def _reconcile_verified_program_enrollments( + request, courserun_id, root_program, verified_program_enrollments, programs +): + """ + Create audit/verified program enrollments as needed to reconcile the + learner's verified enrollment state before enrolling them in the course run. + + Returns: + Response: an error response if reconciliation failed, otherwise None + """ + try: + if len(verified_program_enrollments) == 0: + # No verified enrollments, so it doesn't matter - the user will get an + # audit one. (But make the audit enrollment to not confuse the course run + # process later.) + create_program_enrollments( + request.user, programs, enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE + ) + elif ( + len(verified_program_enrollments) == 1 + and verified_program_enrollments[0].program == root_program + ): + # The verified enrollment that's here is for the root program, so we can + # create a verified enrollment for the other program. + create_program_enrollments( + request.user, programs, enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE + ) + elif ( + len(verified_program_enrollments) == 1 + and verified_program_enrollments[0].program != root_program + ): + # The verified enrollment that's here is _not_ for the root program, so + # we should stop. + log.error( + "add_verified_program_course_enrollment: user %s enrolling in %s has no verified enrollment in %s", + request.user, + courserun_id, + root_program, + ) + return Response(status=status.HTTP_400_BAD_REQUEST) + except ExportComplianceCheckError as exc: + return Response(exc.to_error_detail(), status=status.HTTP_400_BAD_REQUEST) + return None + + @extend_schema( request=list[str], responses={ @@ -910,35 +959,11 @@ def add_verified_program_course_enrollment(request, courserun_id: str): if enrollment.enrollment_mode == EDX_ENROLLMENT_VERIFIED_MODE ] - if len(verified_program_enrollments) == 0: - # No verified enrollments, so it doesn't matter - the user will get an - # audit one. (But make the audit enrollment to not confuse the course run - # process later.) - create_program_enrollments( - request.user, programs, enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE - ) - elif ( - len(verified_program_enrollments) == 1 - and verified_program_enrollments[0].program == root_program - ): - # The verified enrollment that's here is for the root program, so we can - # create a verified enrollment for the other program. - create_program_enrollments( - request.user, programs, enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE - ) - elif ( - len(verified_program_enrollments) == 1 - and verified_program_enrollments[0].program != root_program - ): - # The verified enrollment that's here is _not_ for the root program, so - # we should stop. - log.error( - "add_verified_program_course_enrollment: user %s enrolling in %s has no verified enrollment in %s", - request.user, - courserun_id, - root_program, - ) - return Response(status=status.HTTP_400_BAD_REQUEST) + error_response = _reconcile_verified_program_enrollments( + request, courserun_id, root_program, verified_program_enrollments, programs + ) + if error_response is not None: + return error_response # If we fell out the bottom, we have all verified enrollments, or we've made # sufficient enrollments to fill the gaps. diff --git a/courses/views/v2/views_test.py b/courses/views/v2/views_test.py index b64fccb94b..8b2b564d74 100644 --- a/courses/views/v2/views_test.py +++ b/courses/views/v2/views_test.py @@ -30,6 +30,7 @@ from b2b.models import ContractProgramItem from cms.factories import CoursePageFactory, ProgramPageFactory from cms.serializers import ProgramPageSerializer +from compliance.exceptions import ExportComplianceError from courses.constants import ENROLL_CHANGE_STATUS_UNENROLLED from courses.factories import ( CourseFactory, @@ -1156,6 +1157,24 @@ def test_user_enrollments_create_b2b_run_invalid_v2(user_drf_client, user): assert resp.json() == {"errors": {"run_id": f"Invalid course run id: {run.id}"}} +def test_user_enrollments_create_export_compliance_blocked_v2( + mocker, user_drf_client, user +): + """v2 enrollments API should fail closed when the export compliance check rejects the user.""" + run = CourseRunFactory.create() + exc = ExportComplianceError(user, "REJECT", "102") + mocker.patch( + "courses.serializers.v2.courses.create_run_enrollments", + side_effect=exc, + ) + resp = user_drf_client.post( + reverse("v2:user-enrollments-api-list"), data={"run_id": run.id} + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert resp.json() == {"errors": exc.to_error_detail()} + assert not CourseRunEnrollment.objects.filter(user=user, run=run).exists() + + def test_program_filter_for_b2b_org(user, mock_course_run_clone): """Test that filtering programs by org works as expected.""" @@ -1982,6 +2001,49 @@ def test_add_verified_program_course_enrollment_audit_only_run_falls_back_to_aud assert user.orders.count() == initial_order_count +@responses.activate +def test_add_verified_program_course_enrollment_export_compliance_blocked( + mocker, user, user_drf_client +): + """ + A single-program audit enrollment should fail closed (400) when the export + compliance check rejects the user, instead of creating an enrollment. + """ + responses.add( + responses.GET, + f"{settings.OPENEDX_API_BASE_URL}/api/enrollment/v1/enrollments", + json={ + "results": [ + {"mode": EDX_ENROLLMENT_AUDIT_MODE, "is_active": True}, + ], + }, + status=status.HTTP_200_OK, + ) + + prog_enrollment = ProgramEnrollmentFactory.create( + user=user, enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE + ) + program = prog_enrollment.program + + course_run = CourseRunFactory.create() + program.add_requirement(course_run.course) + + exc = ExportComplianceError(user, "REJECT", "102") + mocker.patch("courses.views.v2.create_run_enrollments", side_effect=exc) + + resp = user_drf_client.post( + reverse( + "v2:add_verified_program_course_enrollment", + kwargs={"courserun_id": course_run.courseware_id}, + ), + data=[program.readable_id], + ) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert resp.json() == exc.to_error_detail() + assert not CourseRunEnrollment.objects.filter(user=user, run=course_run).exists() + + @pytest.mark.skip_nplusone_check @responses.activate @pytest.mark.parametrize( @@ -2160,6 +2222,51 @@ def test_add_nested_verified_program_course_enrollment( ) +@responses.activate +def test_add_nested_verified_program_course_enrollment_export_compliance_blocked( + mocker, user, user_drf_client +): + """ + Reconciling program enrollments across a multi-program request should fail + closed (400) when the export compliance check rejects the user. + """ + responses.add( + responses.GET, + f"{settings.OPENEDX_API_BASE_URL}/api/enrollment/v1/enrollments", + json={"results": [{"mode": EDX_ENROLLMENT_AUDIT_MODE, "is_active": True}]}, + status=status.HTTP_200_OK, + ) + + base_program = ProgramFactory.create(display_mode=None) + crogram = ProgramFactory.create(display_mode="course") + course_run = CourseRunFactory.create() + + crogram.add_requirement(course_run.course) + base_program.add_requirement(crogram) + + # Neither program has a verified enrollment, so reconciliation will try to + # create audit program enrollments via create_program_enrollments - which + # is where the export compliance check happens. + ProgramEnrollmentFactory.create( + program=base_program, user=user, enrollment_mode=EDX_ENROLLMENT_AUDIT_MODE + ) + + exc = ExportComplianceError(user, "REJECT", "102") + mocker.patch("courses.views.v2.create_program_enrollments", side_effect=exc) + + resp = user_drf_client.post( + reverse( + "v2:add_verified_program_course_enrollment", + kwargs={"courserun_id": course_run.courseware_id}, + ), + data=[base_program.readable_id, crogram.readable_id], + ) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert resp.json() == exc.to_error_detail() + assert not ProgramEnrollment.objects.filter(user=user, program=crogram).exists() + + @pytest.mark.skip_nplusone_check @pytest.mark.parametrize( "sync_on_load,flag_enabled,sync_raises", # noqa: PT006 diff --git a/courses/views/v3/__init__.py b/courses/views/v3/__init__.py index e72ae9eacd..d01d6ee3ba 100644 --- a/courses/views/v3/__init__.py +++ b/courses/views/v3/__init__.py @@ -25,6 +25,7 @@ from rest_framework.response import Response from b2b.models import ContractPage +from compliance.exceptions import ExportComplianceCheckError from courses.api import create_program_enrollments, deactivate_run_enrollment from courses.constants import COURSE_KEY_PATTERN, ENROLL_CHANGE_STATUS_UNENROLLED from courses.models import ( @@ -235,7 +236,11 @@ def create(self, request, *args, **kwargs): # noqa: ARG002 return Response(response_serializer.data, status=status.HTTP_200_OK) # Create the enrollment using default enrollment mode (audit) - enrollments = create_program_enrollments(request.user, [program]) + try: + enrollments = create_program_enrollments(request.user, [program]) + except ExportComplianceCheckError as exc: + raise serializers.ValidationError(exc.to_error_detail()) from exc + if not enrollments: raise ValueError("Failed to create program enrollment.") # noqa: EM101 response_serializer = ProgramEnrollmentSerializer(enrollments[0]) diff --git a/courses/views/v3/views_test.py b/courses/views/v3/views_test.py index cbbace7451..475631c99f 100644 --- a/courses/views/v3/views_test.py +++ b/courses/views/v3/views_test.py @@ -12,6 +12,7 @@ from rest_framework import status from rest_framework.test import APIClient +from compliance.exceptions import ExportComplianceError from courses.conftest import B2BCourses, UserWithEnrollmentsAndCerts from courses.constants import ( ENROLL_CHANGE_STATUS_UNENROLLED, @@ -644,6 +645,25 @@ def test_create_program_enrollment_not_found(user_drf_client): assert resp.status_code == status.HTTP_400_BAD_REQUEST +def test_create_program_enrollment_export_compliance_blocked( + mocker, user_drf_client, user +): + """POST should fail closed when the export compliance check rejects the user.""" + program = ProgramFactory.create(live=True) + exc = ExportComplianceError(user, "REJECT", "102") + mocker.patch("courses.views.v3.create_program_enrollments", side_effect=exc) + + resp = user_drf_client.post( + reverse("v3:user_program_enrollments_api-list"), + data={"program_id": program.id}, + format="json", + ) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert resp.json() == {"errors": exc.to_error_detail()} + assert not ProgramEnrollment.objects.filter(user=user, program=program).exists() + + def test_create_program_enrollment_unauthenticated(): """POST without authentication returns 401 or 403.""" program = ProgramFactory.create(live=True) diff --git a/drf_lint_baseline.json b/drf_lint_baseline.json index 03b4fdf46d..587718c412 100644 --- a/drf_lint_baseline.json +++ b/drf_lint_baseline.json @@ -8,8 +8,8 @@ "cms/serializers.py:97:12:ORM001", "courses/serializers/base.py:53:16:ORM001", "courses/serializers/v1/base.py:75:20:ORM002", - "courses/serializers/v1/courses.py:171:18:ORM001", - "courses/serializers/v1/courses.py:57:16:ORM001", + "courses/serializers/v1/courses.py:172:18:ORM001", + "courses/serializers/v1/courses.py:58:16:ORM001", "courses/serializers/v1/programs.py:181:12:ORM001", "courses/serializers/v1/programs.py:196:12:ORM001", "courses/serializers/v1/programs.py:208:12:ORM001", @@ -17,14 +17,14 @@ "courses/serializers/v1/programs.py:303:27:ORM001", "courses/serializers/v1/programs.py:318:16:ORM001", "courses/serializers/v1/programs.py:335:17:ORM001", - "courses/serializers/v2/courses.py:272:17:ORM002", - "courses/serializers/v2/courses.py:337:18:ORM001", + "courses/serializers/v2/courses.py:273:17:ORM002", + "courses/serializers/v2/courses.py:338:18:ORM001", "courses/serializers/v2/departments.py:35:40:ORM002", "courses/serializers/v2/departments.py:49:42:ORM002", "courses/serializers/v2/programs.py:385:53:ORM002", "courses/serializers/v2/programs.py:495:12:ORM002", - "courses/serializers/v3/courses.py:111:14:ORM001", - "courses/serializers/v3/courses.py:55:12:ORM002", + "courses/serializers/v3/courses.py:112:14:ORM001", + "courses/serializers/v3/courses.py:56:12:ORM002", "courses/serializers/v3/programs.py:55:22:ORM001", "ecommerce/serializers/__init__.py:204:17:ORM001", "ecommerce/serializers/__init__.py:206:18:ORM001", diff --git a/main/features.py b/main/features.py index 708af7397f..ed2707fd9c 100644 --- a/main/features.py +++ b/main/features.py @@ -13,3 +13,4 @@ ENABLE_PROGRAM_SPECIFIC_PATHWAY_SCHOOLS = ( "mitxonline-12321-program-specific-pathway-schools" ) +EXPORT_COMPLIANCE_CHECK_ENABLED = "EXPORT_COMPLIANCE_CHECK_ENABLED"