Merge pull request #28281 from edx/jhynes/microba-1087

refactor!: Remove unused callback endpoints used for PDF cert generation/updates
This commit is contained in:
Justin Hynes
2021-07-29 07:56:45 -04:00
committed by GitHub
3 changed files with 1 additions and 307 deletions

View File

@@ -2,28 +2,20 @@
import datetime
import json
from uuid import uuid4
import ddt
from django.conf import settings
from django.core.cache import cache
from django.test.client import Client
from django.test.utils import override_settings
from django.urls import reverse
from opaque_keys.edx.locator import CourseLocator
from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.certificates.models import (
CertificateHtmlViewConfiguration,
CertificateStatuses,
ExampleCertificate,
ExampleCertificateSet
)
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from lms.djangoapps.certificates.utils import get_certificate_url
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@@ -39,149 +31,6 @@ FEATURES_WITH_CUSTOM_CERTS_ENABLED = {
FEATURES_WITH_CUSTOM_CERTS_ENABLED.update(FEATURES_WITH_CERTS_ENABLED)
@ddt.ddt
class UpdateExampleCertificateViewTest(CacheIsolationTestCase):
"""Tests for the XQueue callback that updates example certificates. """
COURSE_KEY = CourseLocator(org='test', course='test', run='test')
DESCRIPTION = 'test'
TEMPLATE = 'test.pdf'
DOWNLOAD_URL = 'http://www.example.com'
ERROR_REASON = 'Kaboom!'
ENABLED_CACHES = ['default']
def setUp(self):
super().setUp()
self.cert_set = ExampleCertificateSet.objects.create(course_key=self.COURSE_KEY)
self.cert = ExampleCertificate.objects.create(
example_cert_set=self.cert_set,
description=self.DESCRIPTION,
template=self.TEMPLATE,
)
self.url = reverse('update_example_certificate')
# Since rate limit counts are cached, we need to clear
# this before each test.
cache.clear()
def test_update_example_certificate_success(self):
response = self._post_to_view(self.cert, download_url=self.DOWNLOAD_URL)
self._assert_response(response)
self.cert = ExampleCertificate.objects.get()
assert self.cert.status == ExampleCertificate.STATUS_SUCCESS
assert self.cert.download_url == self.DOWNLOAD_URL
def test_update_example_certificate_invalid_key(self):
payload = {
'xqueue_header': json.dumps({
'lms_key': 'invalid'
}),
'xqueue_body': json.dumps({
'username': self.cert.uuid,
'url': self.DOWNLOAD_URL
})
}
response = self.client.post(self.url, data=payload)
assert response.status_code == 404
def test_update_example_certificate_error(self):
response = self._post_to_view(self.cert, error_reason=self.ERROR_REASON)
self._assert_response(response)
self.cert = ExampleCertificate.objects.get()
assert self.cert.status == ExampleCertificate.STATUS_ERROR
assert self.cert.error_reason == self.ERROR_REASON
@ddt.data('xqueue_header', 'xqueue_body')
def test_update_example_certificate_invalid_params(self, missing_param):
payload = {
'xqueue_header': json.dumps({
'lms_key': self.cert.access_key
}),
'xqueue_body': json.dumps({
'username': self.cert.uuid,
'url': self.DOWNLOAD_URL
})
}
del payload[missing_param]
response = self.client.post(self.url, data=payload)
assert response.status_code == 400
def test_update_example_certificate_missing_download_url(self):
payload = {
'xqueue_header': json.dumps({
'lms_key': self.cert.access_key
}),
'xqueue_body': json.dumps({
'username': self.cert.uuid
})
}
response = self.client.post(self.url, data=payload)
assert response.status_code == 400
def test_update_example_certificate_non_json_param(self):
payload = {
'xqueue_header': '{/invalid',
'xqueue_body': '{/invalid'
}
response = self.client.post(self.url, data=payload)
assert response.status_code == 400
def test_unsupported_http_method(self):
response = self.client.get(self.url)
assert response.status_code == 405
def test_bad_request_rate_limiting(self):
payload = {
'xqueue_header': json.dumps({
'lms_key': 'invalid'
}),
'xqueue_body': json.dumps({
'username': self.cert.uuid,
'url': self.DOWNLOAD_URL
})
}
# Exceed the rate limit for invalid requests
# (simulate a DDOS with invalid keys)
for _ in range(100):
response = self.client.post(self.url, data=payload)
if response.status_code == 403:
break
# The final status code should indicate that the rate
# limit was exceeded.
assert response.status_code == 403
def _post_to_view(self, cert, download_url=None, error_reason=None):
"""Simulate a callback from the XQueue to the example certificate end-point. """
header = {'lms_key': cert.access_key}
body = {'username': cert.uuid}
if download_url is not None:
body['url'] = download_url
if error_reason is not None:
body['error'] = 'error'
body['error_reason'] = self.ERROR_REASON
payload = {
'xqueue_header': json.dumps(header),
'xqueue_body': json.dumps(body)
}
return self.client.post(self.url, data=payload)
def _assert_response(self, response):
"""Check the response from the callback end-point. """
content = json.loads(response.content.decode('utf-8'))
assert response.status_code == 200
assert content['return_code'] == 0
class CertificatesViewsSiteTests(ModuleStoreTestCase):
"""
Tests for the certificates web/html views

View File

@@ -8,18 +8,11 @@ import logging
from django.contrib.auth import get_user_model
from django.db import transaction
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from opaque_keys.edx.keys import CourseKey
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest
from common.djangoapps.util.request_rate_limiter import BadRequestRateLimiter
from lms.djangoapps.certificates.api import generate_certificate_task
from lms.djangoapps.certificates.models import (
ExampleCertificate,
GeneratedCertificate,
)
from lms.djangoapps.certificates.utils import certificate_status_for_student
log = logging.getLogger(__name__)
@@ -49,147 +42,3 @@ def request_certificate(request):
generate_certificate_task(student, course_key)
return HttpResponse(json.dumps({'add_status': status}), content_type='application/json') # pylint: disable=http-response-with-content-type-json, http-response-with-json-dumps
return HttpResponse(json.dumps({'add_status': 'ERRORANONYMOUSUSER'}), content_type='application/json') # pylint: disable=http-response-with-content-type-json, http-response-with-json-dumps
@csrf_exempt
def update_certificate(request):
"""
Will update GeneratedCertificate for a new certificate or
modify an existing certificate entry.
This view should only ever be accessed by the xqueue server
"""
if request.method == "POST":
xqueue_body = json.loads(request.POST.get('xqueue_body'))
xqueue_header = json.loads(request.POST.get('xqueue_header'))
try:
course_key = CourseKey.from_string(xqueue_body['course_id'])
cert = GeneratedCertificate.eligible_certificates.get(
user__username=xqueue_body['username'],
course_id=course_key,
key=xqueue_header['lms_key'])
except GeneratedCertificate.DoesNotExist:
log.critical(
'Unable to lookup certificate\n'
'xqueue_body: %s\n'
'xqueue_header: %s',
xqueue_body,
xqueue_header
)
return HttpResponse(json.dumps({ # pylint: disable=http-response-with-content-type-json, http-response-with-json-dumps
'return_code': 1,
'content': 'unable to lookup key'
}), content_type='application/json')
user = cert.user
log.warning(f'{course_key} is using V2 certificates. Request to update the certificate for user {user.id} will '
f'be ignored.')
return HttpResponse( # pylint: disable=http-response-with-content-type-json, http-response-with-json-dumps
json.dumps({
'return_code': 1,
'content': 'allowlist certificate'
}),
content_type='application/json'
)
@csrf_exempt
@require_POST
def update_example_certificate(request):
"""Callback from the XQueue that updates example certificates.
Example certificates are used to verify that certificate
generation is configured correctly for a course.
Unlike other certificates, example certificates
are not associated with a particular user or displayed
to students.
For this reason, we need a different end-point to update
the status of generated example certificates.
Arguments:
request (HttpRequest)
Returns:
HttpResponse (200): Status was updated successfully.
HttpResponse (400): Invalid parameters.
HttpResponse (403): Rate limit exceeded for bad requests.
HttpResponse (404): Invalid certificate identifier or access key.
"""
log.info("Received response for example certificate from XQueue.")
rate_limiter = BadRequestRateLimiter()
# Check the parameters and rate limits
# If these are invalid, return an error response.
if rate_limiter.is_rate_limit_exceeded(request):
log.info("Bad request rate limit exceeded for update example certificate end-point.")
return HttpResponseForbidden("Rate limit exceeded")
if 'xqueue_body' not in request.POST:
log.info("Missing parameter 'xqueue_body' for update example certificate end-point")
rate_limiter.tick_request_counter(request)
return JsonResponseBadRequest("Parameter 'xqueue_body' is required.")
if 'xqueue_header' not in request.POST:
log.info("Missing parameter 'xqueue_header' for update example certificate end-point")
rate_limiter.tick_request_counter(request)
return JsonResponseBadRequest("Parameter 'xqueue_header' is required.")
try:
xqueue_body = json.loads(request.POST['xqueue_body'])
xqueue_header = json.loads(request.POST['xqueue_header'])
except (ValueError, TypeError):
log.info("Could not decode params to example certificate end-point as JSON.")
rate_limiter.tick_request_counter(request)
return JsonResponseBadRequest("Parameters must be JSON-serialized.")
# Attempt to retrieve the example certificate record
# so we can update the status.
try:
uuid = xqueue_body.get('username')
access_key = xqueue_header.get('lms_key')
cert = ExampleCertificate.objects.get(uuid=uuid, access_key=access_key)
except ExampleCertificate.DoesNotExist as e:
# If we are unable to retrieve the record, it means the uuid or access key
# were not valid. This most likely means that the request is NOT coming
# from the XQueue. Return a 404 and increase the bad request counter
# to protect against a DDOS attack.
log.info("Could not find example certificate with uuid '%s' and access key '%s'", uuid, access_key)
rate_limiter.tick_request_counter(request)
raise Http404 from e
if 'error' in xqueue_body:
# If an error occurs, save the error message so we can fix the issue.
error_reason = xqueue_body.get('error_reason')
cert.update_status(ExampleCertificate.STATUS_ERROR, error_reason=error_reason)
log.warning(
(
"Error occurred during example certificate generation for uuid '%s'. "
"The error response was '%s'."
), uuid, error_reason
)
else:
# If the certificate generated successfully, save the download URL
# so we can display the example certificate.
download_url = xqueue_body.get('url')
if download_url is None:
rate_limiter.tick_request_counter(request)
log.warning("No download URL provided for example certificate with uuid '%s'.", uuid)
return JsonResponseBadRequest(
"Parameter 'download_url' is required for successfully generated certificates."
)
else:
cert.update_status(ExampleCertificate.STATUS_SUCCESS, download_url=download_url)
log.info("Successfully updated example certificate with uuid '%s'.", uuid)
# Let the XQueue know that we handled the response
return JsonResponse({'return_code': 0})

View File

@@ -889,10 +889,6 @@ if settings.FEATURES.get('ENABLE_OAUTH2_PROVIDER'):
urlpatterns += [
url(r'^certificates/', include('lms.djangoapps.certificates.urls')),
# Backwards compatibility with XQueue, which uses URLs that are not prefixed with /certificates/
url(r'^update_certificate$', certificates_views.update_certificate, name='update_certificate'),
url(r'^update_example_certificate$', certificates_views.update_example_certificate,
name='update_example_certificate'),
url(r'^request_certificate$', certificates_views.request_certificate,
name='request_certificate'),