Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import uuid
from pathlib import Path
from types import SimpleNamespace

import pytest
from faker import Faker
Expand All @@ -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)
Expand Down Expand Up @@ -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"""
Expand Down
27 changes: 26 additions & 1 deletion courses/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()
)
Expand Down Expand Up @@ -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.
Expand Down
180 changes: 180 additions & 0 deletions courses/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"""

Expand Down
18 changes: 11 additions & 7 deletions courses/serializers/v1/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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

Expand Down
20 changes: 13 additions & 7 deletions courses/serializers/v2/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
19 changes: 12 additions & 7 deletions courses/serializers/v3/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down
Loading