Merge pull request #28289 from edx/azan/PROD-2400

Pact Provider Verification Setup
This commit is contained in:
Azan Bin Zahid
2021-08-24 17:19:59 +05:00
committed by GitHub
18 changed files with 955 additions and 0 deletions

View 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

View File

@@ -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"
}
}
}

View 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

View 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})

View File

@@ -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'
))

View File

@@ -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"
}
}
}

View 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

View 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
View 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', ]

View File

@@ -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',
)
]

View File

@@ -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"
}
}
}

View File

@@ -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

View 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})

View File

@@ -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'
))

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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