Merge pull request #28289 from edx/azan/PROD-2400
Pact Provider Verification Setup
This commit is contained in:
33
common/test/pacts/middleware.py
Normal file
33
common/test/pacts/middleware.py
Normal file
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
40
lms/djangoapps/course_api/blocks/tests/pacts/verify_pact.py
Normal file
40
lms/djangoapps/course_api/blocks/tests/pacts/verify_pact.py
Normal file
@@ -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
|
||||
68
lms/djangoapps/course_api/blocks/tests/pacts/views.py
Normal file
68
lms/djangoapps/course_api/blocks/tests/pacts/views.py
Normal file
@@ -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})
|
||||
@@ -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'
|
||||
))
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
40
lms/djangoapps/courseware/tests/pacts/verify_pact.py
Normal file
40
lms/djangoapps/courseware/tests/pacts/verify_pact.py
Normal file
@@ -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
|
||||
73
lms/djangoapps/courseware/tests/pacts/views.py
Normal file
73
lms/djangoapps/courseware/tests/pacts/views.py
Normal file
@@ -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})
|
||||
14
lms/envs/pact.py
Normal file
14
lms/envs/pact.py
Normal file
@@ -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', ]
|
||||
11
lms/urls.py
11
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',
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
136
openedx/core/djangoapps/courseware_api/tests/pacts/views.py
Normal file
136
openedx/core/djangoapps/courseware_api/tests/pacts/views.py
Normal file
@@ -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})
|
||||
@@ -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'
|
||||
))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user