diff --git a/common/test/pacts/middleware.py b/common/test/pacts/middleware.py new file mode 100644 index 0000000000..efd01c5c63 --- /dev/null +++ b/common/test/pacts/middleware.py @@ -0,0 +1,33 @@ +""" +Contains the middleware logic needed during pact verification +""" + +from django.conf import settings +from django.utils.deprecation import MiddlewareMixin + +from common.djangoapps.student.tests.factories import UserFactory + + +class AuthenticationMiddleware(MiddlewareMixin): + """ + Middleware to add default authentication into the requests for pact verification. + + This middleware is required to add a default authenticated user and bypass CSRF validation + into the requests during the pact verification workflow. Without the authentication, the pact verification + process will not work as the apis. + See https://docs.pact.io/faq#how-do-i-test-oauth-or-other-security-headers + """ + def __init__(self, get_response): + super().__init__() + + username = getattr(settings, 'MOCK_USERNAME', 'Mock User') + self.auth_user = UserFactory.create(username=username) + self.get_response = get_response + + def process_view(self, request, view_func, view_args, view_kwargs): # pylint: disable=unused-argument + """ + Add a default authenticated user and remove CSRF checks for a pact request + """ + if request.user.is_anonymous and 'Pact-Authentication' in request.headers: + request.user = self.auth_user + request._dont_enforce_csrf_checks = True # pylint: disable=protected-access diff --git a/lms/djangoapps/course_api/blocks/tests/pacts/api-block-contract.json b/lms/djangoapps/course_api/blocks/tests/pacts/api-block-contract.json new file mode 100644 index 0000000000..36800bace9 --- /dev/null +++ b/lms/djangoapps/course_api/blocks/tests/pacts/api-block-contract.json @@ -0,0 +1,51 @@ +{ + "consumer": { + "name": "frontend-app-learning" + }, + "provider": { + "name": "lms" + }, + "interactions": [ + { + "description": "a request to get course blocks", + "providerState": "Blocks data exists for course_id course-v1:edX+DemoX+Demo_Course", + "request": { + "method": "GET", + "path": "/api/courses/v2/blocks/", + "query": "course_id=course-v1%3AedX%2BDemoX%2BDemo_Course&username=Mock+User&depth=3&requested_fields=children%2Ceffort_activities%2Ceffort_time%2Cshow_gated_sections%2Cgraded%2Cspecial_exam_info%2Chas_scheduled_content" + }, + "response": { + "status": 200, + "headers": { + }, + "body": { + "root": "block-v1:edX+DemoX+Demo_Course+type@course+block@course", + "blocks": { + "block-v1:edX+DemoX+Demo_Course+type@course+block@course": { + "id": "block-v1:edX+DemoX+Demo_Course+type@course+block@course", + "block_id": "course", + "lms_web_url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@course+block@course", + "legacy_web_url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@course+block@course?experience=legacy", + "student_view_url": "/xblock/block-v1:edX+DemoX+Demo_Course+type@course+block@course", + "type": "course", + "display_name": "Demonstration Course" + } + } + }, + "matchingRules": { + "$.body.root": { + "match": "type" + }, + "$.body.blocks": { + "match": "type" + } + } + } + } + ], + "metadata": { + "pactSpecification": { + "version": "2.0.0" + } + } +} diff --git a/lms/djangoapps/course_api/blocks/tests/pacts/verify_pact.py b/lms/djangoapps/course_api/blocks/tests/pacts/verify_pact.py new file mode 100644 index 0000000000..bf45ea019e --- /dev/null +++ b/lms/djangoapps/course_api/blocks/tests/pacts/verify_pact.py @@ -0,0 +1,40 @@ +"""pact test for user service client""" + +import logging +import os + +from django.test import LiveServerTestCase +from django.urls import reverse +from pact import Verifier + +log = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + +PACT_DIR = os.path.dirname(os.path.realpath(__file__)) +PACT_FILE = "api-block-contract.json" + + +class ProviderVerificationServer(LiveServerTestCase): + """ Django Test Live Server for Pact Verification """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.verifier = Verifier( + provider='lms', + provider_base_url=cls.live_server_url, + ) + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + + def test_verify_pact(self): + output, _ = self.verifier.verify_pacts( + os.path.join(PACT_DIR, PACT_FILE), + headers=['Pact-Authentication: Allow', ], + provider_states_setup_url=f"{self.live_server_url}{reverse('provider-state-view')}", + ) + + assert output == 0 diff --git a/lms/djangoapps/course_api/blocks/tests/pacts/views.py b/lms/djangoapps/course_api/blocks/tests/pacts/views.py new file mode 100644 index 0000000000..f351c83ff8 --- /dev/null +++ b/lms/djangoapps/course_api/blocks/tests/pacts/views.py @@ -0,0 +1,68 @@ +""" +Provider state views needed by pact to setup Provider state for pact verification. +""" +import json + +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreIsolationMixin +from xmodule.modulestore.tests.factories import CourseFactory + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory + + +class ProviderState(ModuleStoreIsolationMixin): + """ Provider State Setup """ + + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE + + def clean_db(self, user, course_key): + """ Delete objects from SQL DB and clean mongodb instance """ + + CourseEnrollment.objects.filter(course_id=course_key, user=user).delete() + + try: + self.end_modulestore_isolation() + except IndexError: + pass + + def course_setup(self, request): + """ Setup course data """ + + course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') + + self.clean_db(request.user, course_key) + self.start_modulestore_isolation() + + demo_course = CourseFactory.create( + org=course_key.org, + course=course_key.course, + run=course_key.run, + display_name="Demonstration Course", + modulestore=self.store + ) + + CourseEnrollmentFactory.create(user=request.user, course_id=demo_course.id) + + +@csrf_exempt +@require_POST +def provider_state(request): + """ + Provider state setup view needed by pact verifier. + """ + + state_setup_mapping = { + 'Blocks data exists for course_id course-v1:edX+DemoX+Demo_Course': ProviderState().course_setup, + } + request_body = json.loads(request.body) + state = request_body.get('state') + + if state in state_setup_mapping: + print('Setting up provider state for state value: {}'.format(state)) + state_setup_mapping[state](request) + + return JsonResponse({'result': state}) diff --git a/lms/djangoapps/course_api/blocks/urls.py b/lms/djangoapps/course_api/blocks/urls.py index e5f684aa42..bf92b8fc3f 100644 --- a/lms/djangoapps/course_api/blocks/urls.py +++ b/lms/djangoapps/course_api/blocks/urls.py @@ -38,3 +38,11 @@ urlpatterns = [ name="blocks_in_course" ), ] + +if getattr(settings, 'PROVIDER_STATES_URL', None): + from .tests.pacts.views import provider_state + urlpatterns.append(url( + r'^pact/provider_states/$', + provider_state, + name='provider-state-view' + )) diff --git a/lms/djangoapps/courseware/tests/pacts/course-xblock-handler-contract.json b/lms/djangoapps/courseware/tests/pacts/course-xblock-handler-contract.json new file mode 100644 index 0000000000..c905ede8d1 --- /dev/null +++ b/lms/djangoapps/courseware/tests/pacts/course-xblock-handler-contract.json @@ -0,0 +1,63 @@ +{ + "consumer": { + "name": "frontend-app-learning" + }, + "provider": { + "name": "lms" + }, + "interactions": [ + { + "description": "a request to set sequence position against activeUnitIndex", + "providerState": "sequence position data exists for course_id course-v1:edX+DemoX+Demo_Course, sequence_id block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions and activeUnitIndex 0", + "request": { + "method": "POST", + "path": "/courses/course-v1:edX+DemoX+Demo_Course/xblock/block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions/handler/goto_position", + "body": { + "position": 1 + } + }, + "response": { + "status": 200, + "headers": { + }, + "body": { + "success": true + }, + "matchingRules": { + "$.body.success": { + "match": "type" + } + } + } + }, + { + "description": "a request to get completion block", + "providerState": "completion block data exists for course_id course-v1:edX+DemoX+Demo_Course, sequence_id block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions and usageId block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c", + "request": { + "method": "POST", + "path": "/courses/course-v1:edX+DemoX+Demo_Course/xblock/block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions/handler/get_completion", + "body": { + "usage_key": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c" + } + }, + "response": { + "status": 200, + "headers": { + }, + "body": { + "complete": true + }, + "matchingRules": { + "$.body.complete": { + "match": "type" + } + } + } + } + ], + "metadata": { + "pactSpecification": { + "version": "2.0.0" + } + } +} diff --git a/lms/djangoapps/courseware/tests/pacts/verify_pact.py b/lms/djangoapps/courseware/tests/pacts/verify_pact.py new file mode 100644 index 0000000000..55e9b73631 --- /dev/null +++ b/lms/djangoapps/courseware/tests/pacts/verify_pact.py @@ -0,0 +1,40 @@ +"""pact test for user service client""" + +import logging +import os + +from django.test import LiveServerTestCase +from django.urls import reverse +from pact import Verifier + +log = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + +PACT_DIR = os.path.dirname(os.path.realpath(__file__)) +PACT_FILE = "course-xblock-handler-contract.json" + + +class ProviderVerificationServer(LiveServerTestCase): + """ Django Test Live Server for Pact Verification """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.verifier = Verifier( + provider='lms', + provider_base_url=cls.live_server_url, + ) + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + + def test_verify_pact(self): + output, _ = self.verifier.verify_pacts( + os.path.join(PACT_DIR, PACT_FILE), + headers=['Pact-Authentication: Allow', ], + provider_states_setup_url=f"{self.live_server_url}{reverse('courseware_xblock_handler_provider_state')}", + ) + + assert output == 0 diff --git a/lms/djangoapps/courseware/tests/pacts/views.py b/lms/djangoapps/courseware/tests/pacts/views.py new file mode 100644 index 0000000000..5cd8416521 --- /dev/null +++ b/lms/djangoapps/courseware/tests/pacts/views.py @@ -0,0 +1,73 @@ +""" +Provider state views needed by pact to setup Provider state for pact verification. +""" +import json + +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreIsolationMixin +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + + +class ProviderState(ModuleStoreIsolationMixin): + """ Provider State Setup """ + + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE + + def clean_db(self, user, course_key): # pylint: disable=unused-argument + """ clean mongodb instance """ + + try: + self.end_modulestore_isolation() + except IndexError: + pass + + def course_setup(self, request): + """ Setup course data """ + + course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') + + self.clean_db(request.user, course_key) + self.start_modulestore_isolation() + + demo_course = CourseFactory.create( + org=course_key.org, + course=course_key.course, + run=course_key.run, + display_name="Demonstration Course", + modulestore=self.store + ) + + section = ItemFactory.create( + parent_location=demo_course.location, + category="chapter", + ) + + ItemFactory.create( + parent_location=section.location, + category="sequential", + display_name="basic_questions", + ) + + +@csrf_exempt +@require_POST +def provider_state(request): + """ + Provider state setup view needed by pact verifier. + """ + + state_setup_mapping = { + 'sequence position data exists for course_id course-v1:edX+DemoX+Demo_Course, sequence_id block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions and activeUnitIndex 0': ProviderState().course_setup, # lint-amnesty, pylint: disable=line-too-long + 'completion block data exists for course_id course-v1:edX+DemoX+Demo_Course, sequence_id block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions and usageId block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c': ProviderState().course_setup, # lint-amnesty, pylint: disable=line-too-long + } + request_body = json.loads(request.body) + state = request_body.get('state') + + if state in state_setup_mapping: + print('Setting up provider state for state value: {}'.format(state)) + state_setup_mapping[state](request) + + return JsonResponse({'result': state}) diff --git a/lms/envs/pact.py b/lms/envs/pact.py new file mode 100644 index 0000000000..df3e3ee51a --- /dev/null +++ b/lms/envs/pact.py @@ -0,0 +1,14 @@ +""" +Settings for Pact Verification Tests. +""" + +from .test import * # pylint: disable=wildcard-import, unused-wildcard-import + +#### Allow Pact Provider States URL #### +PROVIDER_STATES_URL = True + +#### Default User name for Pact Requests Authentication ##### +MOCK_USERNAME = 'Mock User' + +######################### Add Authentication Middleware for Pact Verification Calls ######################### +MIDDLEWARE = MIDDLEWARE + ['common.test.pacts.middleware.AuthenticationMiddleware', ] diff --git a/lms/urls.py b/lms/urls.py index 13e8e7720a..66b98b8502 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -1005,3 +1005,14 @@ if settings.FEATURES.get('ENABLE_BULK_USER_RETIREMENT'): urlpatterns += [ url(r'', include('lms.djangoapps.bulk_user_retirement.urls')), ] + +# Provider States urls +if getattr(settings, 'PROVIDER_STATES_URL', None): + from lms.djangoapps.courseware.tests.pacts.views import provider_state as courseware_xblock_handler_provider_state + urlpatterns += [ + url( + r'^courses/xblock/handler/provider_states', + courseware_xblock_handler_provider_state, + name='courseware_xblock_handler_provider_state', + ) + ] diff --git a/openedx/core/djangoapps/courseware_api/tests/pacts/api-courseware-contract.json b/openedx/core/djangoapps/courseware_api/tests/pacts/api-courseware-contract.json new file mode 100644 index 0000000000..8c192931bf --- /dev/null +++ b/openedx/core/djangoapps/courseware_api/tests/pacts/api-courseware-contract.json @@ -0,0 +1,339 @@ +{ + "consumer": { + "name": "frontend-app-learning" + }, + "provider": { + "name": "lms" + }, + "interactions": [ + { + "description": "a request to get course metadata", + "providerState": "course metadata exists for course_id course-v1:edX+DemoX+Demo_Course", + "request": { + "method": "GET", + "path": "/api/courseware/course/course-v1:edX+DemoX+Demo_Course", + "query": "browser_timezone=Asia%2FKarachi" + }, + "response": { + "status": 200, + "headers": { + }, + "body": { + "access_expiration": { + "expiration_date": "2013-02-05T05:00:00Z", + "masquerading_expired_course": false, + "upgrade_deadline": "2013-02-05T05:00:00Z", + "upgrade_url": "link" + }, + "can_show_upgrade_sock": false, + "content_type_gating_enabled": false, + "end": "2013-02-05T05:00:00Z", + "enrollment": { + "mode": "audit", + "is_active": true + }, + "enrollment_start": "2013-02-05T05:00:00Z", + "enrollment_end": "2013-02-05T05:00:00Z", + "id": "course-v1:edX+DemoX+Demo_Course", + "license": "all-rights-reserved", + "name": "Demonstration Course", + "number": "DemoX", + "offer": { + "code": "code", + "expiration_date": "2013-02-05T05:00:00Z", + "original_price": "$99", + "discounted_price": "$99", + "percentage": 50, + "upgrade_url": "url" + }, + "org": "edX", + "related_programs": null, + "short_description": "", + "start": "2013-02-05T05:00:00Z", + "tabs": [ + { + "title": "Course", + "slug": "courseware", + "priority": 0, + "type": "courseware", + "url": "http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course/home" + } + ], + "user_timezone": null, + "verified_mode": { + "access_expiration_date": "2013-02-05T05:00:00Z", + "currency": "USD", + "currency_symbol": "$", + "price": 149, + "sku": "8CF08E5", + "upgrade_url": "http://localhost:18130/basket/add/?sku=8CF08E5" + }, + "show_calculator": false, + "original_user_is_staff": true, + "can_view_legacy_courseware": true, + "is_staff": true, + "course_access": { + "has_access": true, + "error_code": null, + "developer_message": null, + "user_message": null, + "additional_context_user_message": null, + "user_fragment": null + }, + "notes": { + "enabled": false, + "visible": true + }, + "marketing_url": null, + "celebrations": { + "first_section": false, + "streak_length_to_celebrate": null, + "streak_discount_experiment_enabled": false + }, + "user_has_passing_grade": false, + "course_exit_page_is_active": false, + "certificate_data": { + "cert_status": "audit_passing", + "cert_web_view_url": null, + "download_url": null, + "certificate_available_date": null + }, + "verify_identity_url": null, + "verification_status": "none", + "linkedin_add_to_profile_url": null, + "is_mfe_special_exams_enabled": false, + "is_mfe_proctored_exams_enabled": false, + "user_needs_integrity_signature": false + }, + "matchingRules": { + "$.body.access_expiration.expiration_date": { + "match": "regex", + "regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$" + }, + "$.body.access_expiration.masquerading_expired_course": { + "match": "type" + }, + "$.body.access_expiration.upgrade_deadline": { + "match": "regex", + "regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$" + }, + "$.body.access_expiration.upgrade_url": { + "match": "type" + }, + "$.body.can_show_upgrade_sock": { + "match": "type" + }, + "$.body.content_type_gating_enabled": { + "match": "type" + }, + "$.body.end": { + "match": "regex", + "regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$" + }, + "$.body.enrollment.mode": { + "match": "regex", + "regex": "^(audit|verified)$" + }, + "$.body.enrollment.is_active": { + "match": "type" + }, + "$.body.enrollment_start": { + "match": "regex", + "regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$" + }, + "$.body.enrollment_end": { + "match": "regex", + "regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$" + }, + "$.body.id": { + "match": "regex", + "regex": "[\\w\\-~.:]" + }, + "$.body.license": { + "match": "type" + }, + "$.body.name": { + "match": "type" + }, + "$.body.number": { + "match": "type" + }, + "$.body.offer.code": { + "match": "type" + }, + "$.body.offer.expiration_date": { + "match": "regex", + "regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$" + }, + "$.body.offer.original_price": { + "match": "type" + }, + "$.body.offer.discounted_price": { + "match": "type" + }, + "$.body.offer.percentage": { + "match": "type" + }, + "$.body.offer.upgrade_url": { + "match": "type" + }, + "$.body.org": { + "match": "type" + }, + "$.body.short_description": { + "match": "type" + }, + "$.body.start": { + "match": "regex", + "regex": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$" + }, + "$.body.tabs": { + "min": 1 + }, + "$.body.tabs[*].*": { + "match": "type" + }, + "$.body.verified_mode": { + "match": "type" + }, + "$.body.show_calculator": { + "match": "type" + }, + "$.body.original_user_is_staff": { + "match": "type" + }, + "$.body.can_view_legacy_courseware": { + "match": "type" + }, + "$.body.is_staff": { + "match": "type" + }, + "$.body.course_access": { + "match": "type" + }, + "$.body.course_access.has_access": { + "match": "type" + }, + "$.body.notes.enabled": { + "match": "type" + }, + "$.body.notes.visible": { + "match": "type" + }, + "$.body.celebrations.irst_section": { + "match": "type" + }, + "$.body.celebrations.streak_discount_experiment_enabled": { + "match": "type" + }, + "$.body.user_has_passing_grade": { + "match": "type" + }, + "$.body.course_exit_page_is_active": { + "match": "type" + }, + "$.body.certificate_data.cert_status": { + "match": "type" + }, + "$.body.verification_status": { + "match": "type" + }, + "$.body.is_mfe_special_exams_enabled": { + "match": "type" + }, + "$.body.is_mfe_proctored_exams_enabled": { + "match": "type" + }, + "$.body.user_needs_integrity_signature": { + "match": "type" + } + } + } + }, + { + "description": "a request to get sequence metadata", + "providerState": "sequence metadata data exists for sequence_id block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions", + "request": { + "method": "GET", + "path": "/api/courseware/sequence/block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions" + }, + "response": { + "status": 200, + "headers": { + }, + "body": { + "items": [ + { + "content": "", + "page_title": "Pointing on a Picture", + "type": "problem", + "id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7", + "bookmarked": false, + "path": "Example Week 1: Getting Started > Homework - Question Styles > Pointing on a Picture", + "graded": true, + "contains_content_type_gated_content": false, + "href": "" + } + ], + "item_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions", + "is_time_limited": false, + "is_proctored": false, + "position": null, + "tag": "sequential", + "banner_text": null, + "save_position": false, + "show_completion": false, + "gated_content": { + "prereq_id": null, + "prereq_url": null, + "prereq_section_name": null, + "gated": false, + "gated_section_name": "Homework - Question Styles" + }, + "display_name": "Homework - Question Styles", + "format": "Homework" + }, + "matchingRules": { + "$.body.items": { + "min": 1 + }, + "$.body.items[*].*": { + "match": "type" + }, + "$.body.item_id": { + "match": "type" + }, + "$.body.is_time_limited": { + "match": "type" + }, + "$.body.is_proctored": { + "match": "type" + }, + "$.body.tag": { + "match": "type" + }, + "$.body.save_position": { + "match": "type" + }, + "$.body.show_completion": { + "match": "type" + }, + "$.body.gated_content": { + "match": "type" + }, + "$.body.display_name": { + "match": "type" + }, + "$.body.format": { + "match": "type" + } + } + } + } + ], + "metadata": { + "pactSpecification": { + "version": "2.0.0" + } + } +} diff --git a/openedx/core/djangoapps/courseware_api/tests/pacts/verify_pact.py b/openedx/core/djangoapps/courseware_api/tests/pacts/verify_pact.py new file mode 100644 index 0000000000..10c209d085 --- /dev/null +++ b/openedx/core/djangoapps/courseware_api/tests/pacts/verify_pact.py @@ -0,0 +1,47 @@ +"""pact test for user service client""" + +import logging +import os + +from django.test import LiveServerTestCase +from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag +from pact import Verifier + +from openedx.features.discounts.applicability import DISCOUNT_APPLICABILITY_FLAG + + +log = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + +PACT_DIR = os.path.dirname(os.path.realpath(__file__)) +PACT_FILE = "api-courseware-contract.json" + + +class ProviderVerificationServer(LiveServerTestCase): + """ Live Server for Pact Verification""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.PACT_URL = cls.live_server_url + + cls.verifier = Verifier( + provider='lms', + provider_base_url=cls.PACT_URL, + ) + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + + @override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True) + def test_verify_pact(self): + output, _ = self.verifier.verify_pacts( + os.path.join(PACT_DIR, PACT_FILE), + headers=['Pact-Authentication: Allow', ], + provider_states_setup_url=f"{self.PACT_URL}{reverse('courseware_api:provider-state-view')}", + ) + + assert output == 0 diff --git a/openedx/core/djangoapps/courseware_api/tests/pacts/views.py b/openedx/core/djangoapps/courseware_api/tests/pacts/views.py new file mode 100644 index 0000000000..a283c03d72 --- /dev/null +++ b/openedx/core/djangoapps/courseware_api/tests/pacts/views.py @@ -0,0 +1,136 @@ +""" +Provider state views needed by pact to setup Provider state for pact verification. +""" +import json +from datetime import datetime + +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +from opaque_keys.edx.keys import CourseKey, UsageKey +from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreIsolationMixin +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, ToyCourseFactory + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from common.djangoapps.student.models import CourseEnrollment +from openedx.features.course_duration_limits.models import CourseDurationLimitConfig + + +class ProviderState(ModuleStoreIsolationMixin): + """ Provider State Setup """ + + COURSE_KEY = "course-v1:edX+DemoX+Demo_Course" + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE + + def clean_db(self, user, course_key): + """ Utility method to clean db """ + + CourseEnrollment.objects.filter(course_id=course_key, user=user).delete() + CourseMode.objects.filter(course_id=course_key).delete() + + try: + self.end_modulestore_isolation() + except IndexError: + pass + + def setup_course_metadata(self, request): + """ + Setup course metadata and related database entries for provider state setup. + """ + course_key = CourseKey.from_string(self.COURSE_KEY) + + self.clean_db(request.user, course_key) + self.start_modulestore_isolation() + + demo_course = ToyCourseFactory.create( + org=course_key.org, + course=course_key.course, + run=course_key.run, + display_name="Demonstration Course", + modulestore=self.store, + start=datetime(2024, 1, 1, 1, 1, 1), + end=datetime(2028, 1, 1, 1, 1, 1), + enrollment_start=datetime(2020, 1, 1, 1, 1, 1), + enrollment_end=datetime(2028, 1, 1, 1, 1, 1), + license="all-rights-reserved", + ) + + CourseModeFactory( + course_id=demo_course.id, + mode_slug=CourseMode.AUDIT, + ) + + CourseModeFactory( + course_id=demo_course.id, + mode_slug=CourseMode.VERIFIED, + expiration_datetime=datetime(3028, 1, 1), + min_price=149, + sku='ABCD1234', + ) + + CourseEnrollment.enroll(request.user, demo_course.id, CourseMode.AUDIT) + CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) + + def setup_sequence_metadata(self, request): + """ + Setup course with appropriate sequence for provider state setup. + """ + course_key = CourseKey.from_string(self.COURSE_KEY) + + self.clean_db(request.user, course_key) + self.start_modulestore_isolation() + + demo_course = CourseFactory.create( + org=course_key.org, + course=course_key.course, + run=course_key.run, + display_name="Demonstration Course", + modulestore=self.store, + end=datetime(2028, 1, 1, 1, 1, 1), + enrollment_start=datetime(2020, 1, 1, 1, 1, 1), + enrollment_end=datetime(2028, 1, 1, 1, 1, 1), + ) + section = ItemFactory.create( + parent=demo_course, + category="chapter", + display_name="Example Week 1: Getting Started" + ) + subsection = ItemFactory.create( + location=UsageKey.from_string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions'), + parent=section, + category="sequential", + display_name="Homework - Question Styles", + metadata={'graded': True, 'format': 'Homework'} + ) + ItemFactory.create( + location=UsageKey.from_string( + 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7' + ), + parent=subsection, + category="vertical", + display_name="Pointing on a Picture" + ) + + +@csrf_exempt +@require_POST +def provider_state(request): + """ + Provider state setup view needed by pact verifier. + """ + provider_state_obj = ProviderState() + state_setup_mapping = { + 'course metadata exists for course_id ' + 'course-v1:edX+DemoX+Demo_Course': provider_state_obj.setup_course_metadata, + 'sequence metadata data exists for sequence_id block-v1:' + 'edX+DemoX+Demo_Course+type@sequential+block@basic_questions': provider_state_obj.setup_sequence_metadata, + } + request_body = json.loads(request.body) + state = request_body.get('state') + + if state in state_setup_mapping: + print('Setting up provider state for state value: {}'.format(state)) + state_setup_mapping[state](request) + + return JsonResponse({'result': state}) diff --git a/openedx/core/djangoapps/courseware_api/urls.py b/openedx/core/djangoapps/courseware_api/urls.py index 09903aed67..fd9bf910fc 100644 --- a/openedx/core/djangoapps/courseware_api/urls.py +++ b/openedx/core/djangoapps/courseware_api/urls.py @@ -22,3 +22,11 @@ urlpatterns = [ views.Celebration.as_view(), name="celebration-api"), ] + +if getattr(settings, 'PROVIDER_STATES_URL', None): + from .tests.pacts.views import provider_state + urlpatterns.append(url( + r'^pact/provider_states/$', + provider_state, + name='provider-state-view' + )) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index dd2d6040de..75e674564e 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -123,6 +123,7 @@ click==7.1.2 # -c requirements/edx/../constraints.txt # code-annotations # nltk + # pact-python # user-util code-annotations==1.2.0 # via @@ -696,6 +697,8 @@ packaging==21.0 # via # bleach # drf-yasg +pact-python==1.3.9 + # via edxval path==16.2.0 # via # -r requirements/edx/paver.txt @@ -726,6 +729,7 @@ psutil==5.8.0 # via # -r requirements/edx/paver.txt # edx-django-utils + # pact-python pycountry==20.7.3 # via -r requirements/edx/base.in pycparser==2.20 @@ -859,6 +863,7 @@ requests==2.26.0 # edx-rest-api-client # geoip2 # mailsnake + # pact-python # pyjwkest # python-swiftclient # requests-oauthlib @@ -925,6 +930,7 @@ six==1.16.0 # html5lib # isodate # libsass + # pact-python # paver # pyjwkest # python-dateutil diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 4894c3bda1..6f08946f29 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -162,6 +162,7 @@ click==7.1.2 # code-annotations # edx-lint # nltk + # pact-python # pip-tools # user-util click-log==0.3.2 @@ -908,6 +909,10 @@ packaging==21.0 # pytest # sphinx # tox +pact-python==1.3.9 + # via + # -r requirements/edx/testing.txt + # edxval path==16.2.0 # via # -r requirements/edx/testing.txt @@ -957,6 +962,7 @@ psutil==5.8.0 # via # -r requirements/edx/testing.txt # edx-django-utils + # pact-python # pytest-xdist py==1.10.0 # via @@ -1177,6 +1183,7 @@ requests==2.26.0 # edx-rest-api-client # geoip2 # mailsnake + # pact-python # pyjwkest # python-swiftclient # requests-oauthlib @@ -1267,6 +1274,7 @@ six==1.16.0 # isodate # jsonschema # libsass + # pact-python # paver # pyjwkest # python-dateutil diff --git a/requirements/edx/testing.in b/requirements/edx/testing.in index d6a21f1d48..7052fe67fc 100644 --- a/requirements/edx/testing.in +++ b/requirements/edx/testing.in @@ -48,4 +48,5 @@ tox-battery # Makes tox aware of requirements file changes transifex-client # Command-line interface for the Transifex localization service unidiff # Required by coverage_pytest_plugin pylint-pytest==0.3.0 # A Pylint plugin to suppress pytest-related false positives. +pact-python # Library for contract testing diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 470eb026b6..c9a6493a7a 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -154,6 +154,7 @@ click==7.1.2 # code-annotations # edx-lint # nltk + # pact-python # user-util click-log==0.3.2 # via edx-lint @@ -861,6 +862,11 @@ packaging==21.0 # drf-yasg # pytest # tox +pact-python==1.3.9 + # via + # -r requirements/edx/base.txt + # -r requirements/edx/testing.in + # edxval path==16.2.0 # via # -r requirements/edx/base.txt @@ -903,6 +909,7 @@ psutil==5.8.0 # via # -r requirements/edx/base.txt # edx-django-utils + # pact-python # pytest-xdist py==1.10.0 # via @@ -1109,6 +1116,7 @@ requests==2.26.0 # edx-rest-api-client # geoip2 # mailsnake + # pact-python # pyjwkest # python-swiftclient # requests-oauthlib @@ -1196,6 +1204,7 @@ six==1.16.0 # httpretty # isodate # libsass + # pact-python # paver # pyjwkest # python-dateutil