""" Certificates Tests. """ import itertools import json from unittest import mock import ddt from django.conf import settings from django.test.utils import override_settings from opaque_keys.edx.keys import AssetKey from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import get_lms_link_for_certificate_web_view, reverse_course_url from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util.testing import EventTestMixin, UrlResetMixin from xmodule.contentstore.content import StaticContent from xmodule.contentstore.django import contentstore from xmodule.exceptions import NotFoundError from ..certificates import CERTIFICATE_SCHEMA_VERSION, CertificateManager FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True CERTIFICATE_JSON = { 'name': 'Test certificate', 'description': 'Test description', 'is_active': True, 'version': CERTIFICATE_SCHEMA_VERSION, } CERTIFICATE_JSON_WITH_SIGNATORIES = { 'name': 'Test certificate', 'description': 'Test description', 'version': CERTIFICATE_SCHEMA_VERSION, 'course_title': 'Course Title Override', 'is_active': True, 'signatories': [ { "name": "Bob Smith", "title": "The DEAN.", "signature_image_path": "/c4x/test/CSS101/asset/Signature.png" } ] } C4X_SIGNATORY_PATH = '/c4x/test/CSS101/asset/Signature{}.png' SIGNATORY_PATH = 'asset-v1:test+CSS101+SP2017+type@asset+block@Signature{}.png' # pylint: disable=no-member class HelperMethods: """ Mixin that provides useful methods for certificate configuration tests. """ def _create_fake_images(self, asset_keys): """ Creates fake image files for a list of asset_keys. """ for asset_key_string in asset_keys: asset_key = AssetKey.from_string(asset_key_string) content = StaticContent( asset_key, "Fake asset", "image/png", "data", ) contentstore().save(content) def _add_course_certificates(self, count=1, signatory_count=0, is_active=False, asset_path_format=C4X_SIGNATORY_PATH): """ Create certificate for the course. """ signatories = [ { 'name': 'Name ' + str(i), 'title': 'Title ' + str(i), 'signature_image_path': asset_path_format.format(i), 'id': i } for i in range(signatory_count) ] # create images for signatory signatures except the last signatory self._create_fake_images(signatory['signature_image_path'] for signatory in signatories[:-1]) certificates = [ { 'id': i, 'name': 'Name ' + str(i), 'description': 'Description ' + str(i), 'signatories': signatories, 'version': CERTIFICATE_SCHEMA_VERSION, 'is_active': is_active } for i in range(count) ] self.course.certificates = {'certificates': certificates} self.save_course() # pylint: disable=no-member class CertificatesBaseTestCase: """ Mixin with base test cases for the certificates. """ def _remove_ids(self, content): """ Remove ids from the response. We cannot predict IDs, because they're generated randomly. We use this method to clean up response when creating new certificate. """ certificate_id = content.pop("id") return certificate_id def test_required_fields_are_absent(self): """ Test required fields are absent. """ bad_jsons = [ # must have name of the certificate { 'description': 'Test description', 'version': CERTIFICATE_SCHEMA_VERSION }, # an empty json {}, ] for bad_json in bad_jsons: response = self.client.post( self._url(), data=json.dumps(bad_json), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest" ) self.assertEqual(response.status_code, 400) self.assertNotIn("Location", response) content = json.loads(response.content.decode('utf-8')) self.assertIn("error", content) def test_invalid_json(self): """ Test invalid json handling. """ # Invalid JSON. invalid_json = "{u'name': 'Test Name', u'description': 'Test description'," \ " u'version': " + str(CERTIFICATE_SCHEMA_VERSION) + ", []}" response = self.client.post( self._url(), data=invalid_json, content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest" ) self.assertEqual(response.status_code, 400) self.assertNotIn("Location", response) content = json.loads(response.content.decode('utf-8')) self.assertIn("error", content) def test_certificate_data_validation(self): #Test certificate schema version json_data_1 = { 'version': 100, 'name': 'Test certificate', 'description': 'Test description' } with self.assertRaises(Exception) as context: CertificateManager.validate(json_data_1) self.assertIn( "Unsupported certificate schema version: 100. Expected version: 1.", str(context.exception) ) #Test certificate name is missing json_data_2 = { 'version': CERTIFICATE_SCHEMA_VERSION, 'description': 'Test description' } with self.assertRaises(Exception) as context: CertificateManager.validate(json_data_2) self.assertIn('must have name of the certificate', str(context.exception)) @ddt.ddt @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) class CertificatesListHandlerTestCase( EventTestMixin, CourseTestCase, CertificatesBaseTestCase, HelperMethods, UrlResetMixin ): """ Test cases for certificates_list_handler. """ def setUp(self): # lint-amnesty, pylint: disable=arguments-differ """ Set up CertificatesListHandlerTestCase. """ super().setUp('cms.djangoapps.contentstore.views.certificates.tracker') self.reset_urls() def _url(self): """ Return url for the handler. """ return reverse_course_url('certificates_list_handler', self.course.id) def test_can_create_certificate(self): """ Test that you can create a certificate. """ expected = { 'version': CERTIFICATE_SCHEMA_VERSION, 'name': 'Test certificate', 'description': 'Test description', 'is_active': True, 'signatories': [] } response = self.client.ajax_post( self._url(), data=CERTIFICATE_JSON ) self.assertEqual(response.status_code, 201) self.assertIn("Location", response) content = json.loads(response.content.decode('utf-8')) certificate_id = self._remove_ids(content) self.assertEqual(content, expected) self.assert_event_emitted( 'edx.certificate.configuration.created', course_id=str(self.course.id), configuration_id=certificate_id, ) def test_cannot_create_certificate_if_user_has_no_write_permissions(self): """ Tests user without write permissions on course should not able to create certificate """ user = UserFactory() self.client.login(username=user.username, password='test') response = self.client.ajax_post( self._url(), data=CERTIFICATE_JSON ) self.assertEqual(response.status_code, 403) @override_settings(LMS_BASE=None) def test_no_lms_base_for_certificate_web_view_link(self): test_link = get_lms_link_for_certificate_web_view( course_key=self.course.id, mode='honor' ) self.assertEqual(test_link, None) @override_settings(LMS_BASE="lms_base_url") def test_lms_link_for_certificate_web_view(self): test_url = "//lms_base_url/certificates/" \ "course/" + str(self.course.id) + '?preview=honor' link = get_lms_link_for_certificate_web_view( course_key=self.course.id, mode='honor' ) self.assertEqual(link, test_url) @mock.patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True}) def test_certificate_info_in_response(self): """ Test that certificate has been created and rendered properly with non-audit course mode. """ CourseModeFactory.create(course_id=self.course.id, mode_slug='verified') response = self.client.ajax_post( self._url(), data=CERTIFICATE_JSON_WITH_SIGNATORIES ) self.assertEqual(response.status_code, 201) # in html response result = self.client.get_html(self._url()) self.assertContains(result, 'Test certificate') self.assertContains(result, 'Test description') # in JSON response response = self.client.get_json(self._url()) data = json.loads(response.content.decode('utf-8')) self.assertEqual(len(data), 1) self.assertEqual(data[0]['name'], 'Test certificate') self.assertEqual(data[0]['description'], 'Test description') self.assertEqual(data[0]['version'], CERTIFICATE_SCHEMA_VERSION) @mock.patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True}) def test_certificate_info_not_in_response(self): """ Test that certificate has not been rendered audit only course mode. """ response = self.client.ajax_post( self._url(), data=CERTIFICATE_JSON_WITH_SIGNATORIES ) self.assertEqual(response.status_code, 201) # in html response result = self.client.get_html(self._url()) self.assertNotContains(result, 'Test certificate') def test_unsupported_http_accept_header(self): """ Test if not allowed header present in request. """ response = self.client.get( self._url(), HTTP_ACCEPT="text/plain", ) self.assertEqual(response.status_code, 406) def test_certificate_unsupported_method(self): """ Unit Test: test_certificate_unsupported_method """ resp = self.client.put(self._url()) self.assertEqual(resp.status_code, 405) def test_not_permitted(self): """ Test that when user has not read access to course then permission denied exception should raised. """ test_user_client, test_user = self.create_non_staff_authed_user_client() CourseEnrollment.enroll(test_user, self.course.id) response = test_user_client.ajax_post( self._url(), data=CERTIFICATE_JSON ) self.assertContains(response, "error", status_code=403) def test_audit_course_mode_is_skipped(self): """ Tests audit course mode is skipped when rendering certificates page. """ CourseModeFactory.create(course_id=self.course.id) CourseModeFactory.create(course_id=self.course.id, mode_slug='verified') response = self.client.get_html( self._url(), ) self.assertEqual(response.status_code, 200) self.assertContains(response, 'verified') self.assertNotContains(response, 'audit') def test_audit_only_disables_cert(self): """ Tests audit course mode is skipped when rendering certificates page. """ CourseModeFactory.create(course_id=self.course.id, mode_slug='audit') response = self.client.get_html( self._url(), ) self.assertEqual(response.status_code, 200) self.assertContains(response, 'This course does not use a mode that offers certificates.') self.assertNotContains(response, 'This module is not enabled.') self.assertNotContains(response, 'Loading') @ddt.data( ['audit', 'verified'], ['verified'], ['audit', 'verified', 'credit'], ['verified', 'credit'], ['professional'] ) def test_non_audit_enables_cert(self, slugs): """ Tests audit course mode is skipped when rendering certificates page. """ for slug in slugs: CourseModeFactory.create(course_id=self.course.id, mode_slug=slug) response = self.client.get_html( self._url(), ) self.assertEqual(response.status_code, 200) self.assertNotContains(response, 'This course does not use a mode that offers certificates.') self.assertNotContains(response, 'This module is not enabled.') self.assertContains(response, 'Loading') def test_assign_unique_identifier_to_certificates(self): """ Test certificates have unique ids """ self._add_course_certificates(count=2) json_data = { 'version': CERTIFICATE_SCHEMA_VERSION, 'name': 'New test certificate', 'description': 'New test description', 'is_active': True, 'signatories': [] } response = self.client.post( self._url(), data=json.dumps(json_data), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) new_certificate = json.loads(response.content.decode('utf-8')) for prev_certificate in self.course.certificates['certificates']: self.assertNotEqual(new_certificate.get('id'), prev_certificate.get('id')) @ddt.ddt @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) class CertificatesDetailHandlerTestCase( EventTestMixin, CourseTestCase, CertificatesBaseTestCase, HelperMethods, UrlResetMixin ): """ Test cases for CertificatesDetailHandlerTestCase. """ _id = 0 def setUp(self): # pylint: disable=arguments-differ """ Set up CertificatesDetailHandlerTestCase. """ super().setUp('cms.djangoapps.contentstore.views.certificates.tracker') self.reset_urls() def _url(self, cid=-1): """ Return url for the handler. """ cid = cid if cid > 0 else self._id return reverse_course_url( 'certificates_detail_handler', self.course.id, kwargs={'certificate_id': cid}, ) def test_can_create_new_certificate_if_it_does_not_exist(self): """ PUT/POST new certificate. """ expected = { 'id': 666, 'version': CERTIFICATE_SCHEMA_VERSION, 'name': 'Test certificate', 'description': 'Test description', 'is_active': True, 'course_title': 'Course Title Override', 'signatories': [] } response = self.client.put( self._url(cid=666), data=json.dumps(expected), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) content = json.loads(response.content.decode('utf-8')) self.assertEqual(content, expected) self.assert_event_emitted( 'edx.certificate.configuration.created', course_id=str(self.course.id), configuration_id=666, ) def test_can_edit_certificate(self): """ Edit certificate, check its id and modified fields. """ self._add_course_certificates(count=2) expected = { 'id': 1, 'version': CERTIFICATE_SCHEMA_VERSION, 'name': 'New test certificate', 'description': 'New test description', 'is_active': True, 'course_title': 'Course Title Override', 'signatories': [] } response = self.client.put( self._url(cid=1), data=json.dumps(expected), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) content = json.loads(response.content.decode('utf-8')) self.assertEqual(content, expected) self.assert_event_emitted( 'edx.certificate.configuration.modified', course_id=str(self.course.id), configuration_id=1, ) self.reload_course() # Verify that certificate is properly updated in the course. course_certificates = self.course.certificates['certificates'] self.assertEqual(len(course_certificates), 2) self.assertEqual(course_certificates[1].get('name'), 'New test certificate') self.assertEqual(course_certificates[1].get('description'), 'New test description') def test_can_edit_certificate_without_is_active(self): """ Tests user should be able to edit certificate, if is_active attribute is not present for given certificate. Old courses might not have is_active attribute in certificate data. """ certificates = [ { 'id': 1, 'name': 'certificate with is_active', 'description': 'Description ', 'signatories': [], 'version': CERTIFICATE_SCHEMA_VERSION, } ] self.course.certificates = {'certificates': certificates} self.save_course() expected = { 'id': 1, 'version': CERTIFICATE_SCHEMA_VERSION, 'name': 'New test certificate', 'description': 'New test description', 'is_active': True, 'course_title': 'Course Title Override', 'signatories': [] } response = self.client.post( self._url(cid=1), data=json.dumps(expected), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 201) content = json.loads(response.content.decode('utf-8')) self.assertEqual(content, expected) @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_can_delete_certificate_with_signatories(self, signatory_path): """ Delete certificate """ self._add_course_certificates(count=2, signatory_count=1, asset_path_format=signatory_path) response = self.client.delete( self._url(cid=1), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 204) self.assert_event_emitted( 'edx.certificate.configuration.deleted', course_id=str(self.course.id), configuration_id='1', ) self.reload_course() # Verify that certificates are properly updated in the course. certificates = self.course.certificates['certificates'] self.assertEqual(len(certificates), 1) self.assertEqual(certificates[0].get('name'), 'Name 0') self.assertEqual(certificates[0].get('description'), 'Description 0') def test_can_delete_certificate_with_slash_prefix_signatory(self): """ Delete certificate """ self._add_course_certificates(count=2, signatory_count=1, asset_path_format="/" + SIGNATORY_PATH) response = self.client.delete( self._url(cid=1), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 204) self.assert_event_emitted( 'edx.certificate.configuration.deleted', course_id=str(self.course.id), configuration_id='1', ) self.reload_course() # Verify that certificates are properly updated in the course. certificates = self.course.certificates['certificates'] self.assertEqual(len(certificates), 1) self.assertEqual(certificates[0].get('name'), 'Name 0') self.assertEqual(certificates[0].get('description'), 'Description 0') @ddt.data("not_a_valid_asset_key{}.png", "/not_a_valid_asset_key{}.png") def test_can_delete_certificate_with_invalid_signatory(self, signatory_path): """ Delete certificate """ self._add_course_certificates(count=2, signatory_count=1, asset_path_format=signatory_path) response = self.client.delete( self._url(cid=1), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 204) self.assert_event_emitted( 'edx.certificate.configuration.deleted', course_id=str(self.course.id), configuration_id='1', ) self.reload_course() # Verify that certificates are properly updated in the course. certificates = self.course.certificates['certificates'] self.assertEqual(len(certificates), 1) self.assertEqual(certificates[0].get('name'), 'Name 0') self.assertEqual(certificates[0].get('description'), 'Description 0') @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_delete_certificate_without_write_permissions(self, signatory_path): """ Tests certificate deletion without write permission on course. """ self._add_course_certificates(count=2, signatory_count=1, asset_path_format=signatory_path) user = UserFactory() self.client.login(username=user.username, password='test') response = self.client.delete( self._url(cid=1), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 403) @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_delete_certificate_without_global_staff_permissions(self, signatory_path): """ Tests deletion of an active certificate without global staff permission on course. """ self._add_course_certificates(count=2, signatory_count=1, is_active=True, asset_path_format=signatory_path) user = UserFactory() for role in [CourseInstructorRole, CourseStaffRole]: role(self.course.id).add_users(user) self.client.login(username=user.username, password='test') response = self.client.delete( self._url(cid=1), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 403) @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_update_active_certificate_without_global_staff_permissions(self, signatory_path): """ Tests update of an active certificate without global staff permission on course. """ self._add_course_certificates(count=2, signatory_count=1, is_active=True, asset_path_format=signatory_path) cert_data = { 'id': 1, 'version': CERTIFICATE_SCHEMA_VERSION, 'name': 'New test certificate', 'description': 'New test description', 'course_title': 'Course Title Override', 'org_logo_path': '', 'is_active': False, 'signatories': [] } user = UserFactory() for role in [CourseInstructorRole, CourseStaffRole]: role(self.course.id).add_users(user) self.client.login(username=user.username, password='test') response = self.client.put( self._url(cid=1), data=json.dumps(cert_data), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 403) def test_delete_non_existing_certificate(self): """ Try to delete a non existing certificate. It should return status code 404 Not found. """ self._add_course_certificates(count=2) response = self.client.delete( self._url(cid=100), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 404) @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_can_delete_signatory(self, signatory_path): """ Delete an existing certificate signatory """ self._add_course_certificates(count=2, signatory_count=3, asset_path_format=signatory_path) certificates = self.course.certificates['certificates'] signatory = certificates[1].get("signatories")[1] image_asset_location = AssetKey.from_string(signatory['signature_image_path']) content = contentstore().find(image_asset_location) self.assertIsNotNone(content) test_url = '{}/signatories/1'.format(self._url(cid=1)) response = self.client.delete( test_url, content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 204) self.reload_course() # Verify that certificates are properly updated in the course. certificates = self.course.certificates['certificates'] self.assertEqual(len(certificates[1].get("signatories")), 2) # make sure signatory signature image is deleted too self.assertRaises(NotFoundError, contentstore().find, image_asset_location) @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_deleting_signatory_without_signature(self, signatory_path): """ Delete an signatory whose signature image is already removed or does not exist """ self._add_course_certificates(count=2, signatory_count=4, asset_path_format=signatory_path) test_url = '{}/signatories/3'.format(self._url(cid=1)) response = self.client.delete( test_url, content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 204) def test_delete_signatory_non_existing_certificate(self): """ Try to delete a non existing certificate signatory. It should return status code 404 Not found. """ self._add_course_certificates(count=2) test_url = '{}/signatories/1'.format(self._url(cid=100)) response = self.client.delete( test_url, content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 404) @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_certificate_activation_success(self, signatory_path): """ Activate and Deactivate the course certificate """ test_url = reverse_course_url('certificate_activation_handler', self.course.id) self._add_course_certificates(count=1, signatory_count=2, asset_path_format=signatory_path) is_active = True for i in range(2): if i == 1: is_active = not is_active response = self.client.post( test_url, data=json.dumps({"is_active": is_active}), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest" ) self.assertEqual(response.status_code, 200) course = self.store.get_course(self.course.id) certificates = course.certificates['certificates'] self.assertEqual(certificates[0].get('is_active'), is_active) cert_event_type = 'activated' if is_active else 'deactivated' self.assert_event_emitted( '.'.join(['edx.certificate.configuration', cert_event_type]), course_id=str(self.course.id), ) @ddt.data(*itertools.product([True, False], [C4X_SIGNATORY_PATH, SIGNATORY_PATH])) @ddt.unpack def test_certificate_activation_without_write_permissions(self, activate, signatory_path): """ Tests certificate Activate and Deactivate should not be allowed if user does not have write permissions on course. """ test_url = reverse_course_url('certificate_activation_handler', self.course.id) self._add_course_certificates(count=1, signatory_count=2, asset_path_format=signatory_path) user = UserFactory() self.client.login(username=user.username, password='test') response = self.client.post( test_url, data=json.dumps({"is_active": activate}), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest" ) self.assertEqual(response.status_code, 403) @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_certificate_activation_failure(self, signatory_path): """ Certificate activation should fail when user has not read access to course then permission denied exception should raised. """ test_url = reverse_course_url('certificate_activation_handler', self.course.id) test_user_client, test_user = self.create_non_staff_authed_user_client() CourseEnrollment.enroll(test_user, self.course.id) self._add_course_certificates(count=1, signatory_count=2, asset_path_format=signatory_path) response = test_user_client.post( test_url, data=json.dumps({"is_active": True}), content_type="application/json", HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 403) course = self.store.get_course(self.course.id) certificates = course.certificates['certificates'] self.assertEqual(certificates[0].get('is_active'), False)