Files
edx-platform/common/djangoapps/student/tests/test_email.py
2021-03-19 15:30:01 +05:00

688 lines
28 KiB
Python

import json
import unittest
from string import capwords
from unittest.mock import Mock, patch
import ddt
import pytest
from django.conf import settings
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core import mail
from django.db import transaction
from django.http import HttpResponse
from django.test import TransactionTestCase, override_settings
from django.test.client import RequestFactory
from django.urls import reverse
from django.utils.html import escape
from edx_toggles.toggles.testutils import override_waffle_flag
from common.djangoapps.edxmako.shortcuts import marketing_link
from common.djangoapps.student.email_helpers import generate_proctoring_requirements_email_context
from common.djangoapps.student.emails import send_proctoring_requirements_email
from common.djangoapps.student.models import PendingEmailChange, Registration, UserProfile
from common.djangoapps.student.tests.factories import PendingEmailChangeFactory, UserFactory
from common.djangoapps.student.views import (
SETTING_CHANGE_INITIATED,
confirm_email_change,
do_email_change_request,
validate_new_email
)
from common.djangoapps.third_party_auth.views import inactive_user_view
from common.djangoapps.util.testing import EventTestMixin
from lms.djangoapps.courseware.toggles import COURSEWARE_PROCTORING_IMPROVEMENTS
from lms.djangoapps.verify_student.services import IDVerificationService
from openedx.core.djangoapps.ace_common.tests.mixins import EmailTemplateTagMixin
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme
from openedx.core.djangolib.testing.utils import CacheIsolationMixin, CacheIsolationTestCase
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
class TestException(Exception):
"""
Exception used for testing that nothing will catch explicitly
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def mock_render_to_string(template_name, context):
"""
Return a string that encodes template_name and context
"""
return str((template_name, sorted(context.items())))
def mock_render_to_response(template_name, context):
"""
Return an HttpResponse with content that encodes template_name and context
"""
# This simulates any db access in the templates.
UserProfile.objects.exists()
return HttpResponse(mock_render_to_string(template_name, context))
class EmailTestMixin:
"""
Adds useful assertions for testing `email_user`
"""
def assertEmailUser(self, email_user, subject_template, subject_context, body_template, body_context):
"""
Assert that `email_user` was used to send and email with the supplied subject and body
`email_user`: The mock `django.contrib.auth.models.User.email_user` function
to verify
`subject_template`: The template to have been used for the subject
`subject_context`: The context to have been used for the subject
`body_template`: The template to have been used for the body
`body_context`: The context to have been used for the body
"""
email_user.assert_called_with(
mock_render_to_string(subject_template, subject_context),
mock_render_to_string(body_template, body_context),
configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
)
def append_allowed_hosts(self, hostname):
"""
Append hostname to settings.ALLOWED_HOSTS
"""
settings.ALLOWED_HOSTS.append(hostname)
self.addCleanup(settings.ALLOWED_HOSTS.pop)
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class ActivationEmailTests(EmailTemplateTagMixin, CacheIsolationTestCase):
"""
Test sending of the activation email.
"""
ACTIVATION_SUBJECT = f"Action Required: Activate your {settings.PLATFORM_NAME} account"
# Text fragments we expect in the body of an email
# sent from an OpenEdX installation.
OPENEDX_FRAGMENTS = [
(
"Use the link below to activate your account to access engaging, "
"high-quality {platform_name} courses. Note that you will not be able to log back into your "
"account until you have activated it.".format(
platform_name=settings.PLATFORM_NAME
)
),
f"{settings.LMS_ROOT_URL}/activate/",
"If you need help, please use our web form at ", (
settings.ACTIVATION_EMAIL_SUPPORT_LINK or settings.SUPPORT_SITE_LINK
),
settings.CONTACT_EMAIL,
"This email message was automatically sent by ",
settings.LMS_ROOT_URL,
" because someone attempted to create an account on {platform_name}".format(
platform_name=settings.PLATFORM_NAME
),
" using this email address."
]
@ddt.data('plain_text', 'html')
def test_activation_email(self, test_body_type):
self._create_account()
self._assert_activation_email(self.ACTIVATION_SUBJECT, self.OPENEDX_FRAGMENTS, test_body_type)
@with_comprehensive_theme("edx.org")
@ddt.data('plain_text', 'html')
def test_activation_email_edx_domain(self, test_body_type):
self._create_account()
self._assert_activation_email(self.ACTIVATION_SUBJECT, self.OPENEDX_FRAGMENTS, test_body_type)
def _create_account(self):
"""
Create an account, triggering the activation email.
"""
url = reverse('create_account')
params = {
'username': 'test_user',
'email': 'test_user@example.com',
'password': 'edx',
'name': 'Test User',
'honor_code': True,
'terms_of_service': True
}
resp = self.client.post(url, params)
assert resp.status_code == 200, "Could not create account (status {status}). The response was {response}"\
.format(status=resp.status_code, response=resp.content)
def _assert_activation_email(self, subject, body_fragments, test_body_type):
"""
Verify that the activation email was sent.
"""
assert len(mail.outbox) == 1
msg = mail.outbox[0]
assert msg.subject == subject
body_text = {
'plain_text': msg.body,
'html': msg.alternatives[0][0]
}
assert test_body_type in body_text
body_to_be_tested = body_text[test_body_type]
for fragment in body_fragments:
assert fragment in body_to_be_tested
def test_do_not_send_email_and_do_activate(self):
"""
Tests that when an inactive user logs-in using the social auth,
an activation email is not sent.
"""
pipeline_partial = {
'kwargs': {
'social': {
'uid': 'fake uid'
}
}
}
user = UserFactory(is_active=False)
Registration().register(user)
request = RequestFactory().get(settings.SOCIAL_AUTH_INACTIVE_USER_URL)
request.user = user
with patch('common.djangoapps.student.views.management.compose_and_send_activation_email') as email:
with patch('common.djangoapps.third_party_auth.provider.Registry.get_from_pipeline') as reg:
with patch('common.djangoapps.third_party_auth.pipeline.get', return_value=pipeline_partial):
with patch('common.djangoapps.third_party_auth.pipeline.running', return_value=True):
with patch('common.djangoapps.third_party_auth.is_enabled', return_value=True):
reg.skip_email_verification = True
inactive_user_view(request)
assert user.is_active
assert email.called is False, 'method should not have been called'
@patch('common.djangoapps.student.views.management.compose_activation_email')
def test_send_email_to_inactive_user(self, email):
"""
Tests that when an inactive user logs-in using the social auth, system
sends an activation email to the user.
"""
inactive_user = UserFactory(is_active=False)
Registration().register(inactive_user)
request = RequestFactory().get(settings.SOCIAL_AUTH_INACTIVE_USER_URL)
request.user = inactive_user
with patch('common.djangoapps.edxmako.request_context.get_current_request', return_value=request):
with patch('common.djangoapps.third_party_auth.pipeline.running', return_value=False):
inactive_user_view(request)
assert email.called is True, 'method should have been called'
@ddt.ddt
@override_waffle_flag(COURSEWARE_PROCTORING_IMPROVEMENTS, active=True)
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True})
@override_settings(ACCOUNT_MICROFRONTEND_URL='http://account-mfe')
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
class ProctoringRequirementsEmailTests(EmailTemplateTagMixin, ModuleStoreTestCase):
"""
Test sending of the proctoring requirements email.
"""
# pylint: disable=no-member
def setUp(self):
super().setUp()
self.course = CourseFactory(enable_proctored_exams=True)
self.user = UserFactory()
def test_send_proctoring_requirements_email(self):
context = generate_proctoring_requirements_email_context(self.user, self.course.id)
send_proctoring_requirements_email(context)
self._assert_email()
def _assert_email(self):
"""
Verify that the email was sent.
"""
assert len(mail.outbox) == 1
message = mail.outbox[0]
text = message.body
html = message.alternatives[0][0]
assert message.subject == f"Proctoring requirements for {self.course.display_name}"
for fragment in self._get_fragments():
assert fragment in text
assert escape(fragment) in html
def _get_fragments(self):
course_module = modulestore().get_course(self.course.id)
proctoring_provider = capwords(course_module.proctoring_provider.replace('_', ' '))
id_verification_url = IDVerificationService.get_verify_location()
return [
(
"You are enrolled in {} at {}. This course contains proctored exams.".format(
self.course.display_name,
settings.PLATFORM_NAME
)
),
(
"Proctored exams are timed exams that you take while proctoring software monitors "
"your computer's desktop, webcam video, and audio."
),
proctoring_provider,
(
"Carefully review the system requirements as well as the steps to take a proctored "
"exam in order to ensure that you are prepared."
),
settings.PROCTORING_SETTINGS.get('LINK_URLS', {}).get('faq', ''),
("Before taking a graded proctored exam, you must have approved ID verification photos."),
id_verification_url
]
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
class EmailChangeRequestTests(EventTestMixin, EmailTemplateTagMixin, CacheIsolationTestCase):
"""
Test changing a user's email address
"""
def setUp(self, tracker='common.djangoapps.student.views.management.tracker'):
super().setUp(tracker)
self.user = UserFactory.create()
self.new_email = 'new.email@edx.org'
self.req_factory = RequestFactory()
self.request = self.req_factory.post('unused_url', data={
'password': 'test',
'new_email': self.new_email
})
self.request.user = self.user
self.user.email_user = Mock()
def do_email_validation(self, email):
"""
Executes validate_new_email, returning any resulting error message.
"""
try:
validate_new_email(self.request.user, email)
except ValueError as err:
return str(err)
def do_email_change(self, user, email, activation_key=None):
"""
Executes do_email_change_request, returning any resulting error message.
"""
with patch('crum.get_current_request', return_value=self.fake_request):
do_email_change_request(user, email, activation_key)
def assertFailedRequest(self, response_data, expected_error):
"""
Assert that `response_data` indicates a failed request that returns `expected_error`
"""
assert response_data['success'] is False
assert expected_error == response_data['error']
assert self.user.email_user.called is False
@patch('common.djangoapps.student.views.management.render_to_string',
Mock(side_effect=mock_render_to_string, autospec=True)) # lint-amnesty, pylint: disable=line-too-long
def test_duplicate_activation_key(self):
"""
Assert that if two users change Email address simultaneously, no error is thrown
"""
# New emails for the users
user1_new_email = "valid_user1_email@example.com"
user2_new_email = "valid_user2_email@example.com"
# Create a another user 'user2' & make request for change email
user2 = UserFactory.create(email=self.new_email, password="test2")
# Send requests & ensure no error was thrown
self.do_email_change(self.user, user1_new_email)
self.do_email_change(user2, user2_new_email)
def test_invalid_emails(self):
"""
Assert the expected error message from the email validation method for an invalid
(improperly formatted) email address.
"""
for email in ('bad_email', 'bad_email@', '@bad_email'):
assert self.do_email_validation(email) == 'Valid e-mail address required.'
def test_change_email_to_existing_value(self):
"""
Test the error message if user attempts to change email to the existing value.
"""
assert self.do_email_validation(self.user.email) == 'Old email is the same as the new email.'
@patch('django.core.mail.EmailMultiAlternatives.send')
def test_email_failure(self, send_mail):
"""
Test the return value if sending the email for the user to click fails.
"""
send_mail.side_effect = [Exception, None]
with self.assertRaisesRegex(ValueError, 'Unable to send email activation link. Please try again later.'):
self.do_email_change(self.user, "valid@email.com")
self.assert_no_events_were_emitted()
def test_email_success(self):
"""
Test email was sent if no errors encountered.
"""
old_email = self.user.email
new_email = "valid@example.com"
registration_key = "test-registration-key"
self.do_email_change(self.user, new_email, registration_key)
self._assert_email(
subject='Request to change édX account e-mail',
body_fragments=[
'We received a request to change the e-mail associated with',
'your édX account from {old_email} to {new_email}.'.format(
old_email=old_email,
new_email=new_email,
),
'If this is correct, please confirm your new e-mail address by visiting:',
f'http://edx.org/email_confirm/{registration_key}',
'Please do not reply to this e-mail; if you require assistance,',
'check the help section of the édX web site.',
],
)
self.assert_event_emitted(
SETTING_CHANGE_INITIATED, user_id=self.user.id, setting='email', old=old_email, new=new_email
)
def _assert_email(self, subject, body_fragments):
"""
Verify that the email was sent.
"""
assert len(mail.outbox) == 1
assert len(body_fragments) > 1, 'Should provide at least two body fragments'
message = mail.outbox[0]
text = message.body
html = message.alternatives[0][0]
assert message.subject == subject
for body in text, html:
for fragment in body_fragments:
assert fragment in body
@ddt.ddt
@patch('common.djangoapps.student.views.management.render_to_response',
Mock(side_effect=mock_render_to_response, autospec=True)) # lint-amnesty, pylint: disable=line-too-long
@patch('common.djangoapps.student.views.management.render_to_string',
Mock(side_effect=mock_render_to_string, autospec=True)) # lint-amnesty, pylint: disable=line-too-long
class EmailChangeConfirmationTests(EmailTestMixin, EmailTemplateTagMixin, CacheIsolationMixin, TransactionTestCase):
"""
Test that confirmation of email change requests function even in the face of exceptions thrown while sending email
"""
def setUp(self):
super().setUp()
self.clear_caches()
self.addCleanup(self.clear_caches)
self.user = UserFactory.create()
self.profile = UserProfile.objects.get(user=self.user)
self.req_factory = RequestFactory()
self.request = self.req_factory.get('unused_url')
self.request.user = self.user
self.pending_change_request = PendingEmailChangeFactory.create(user=self.user)
self.key = self.pending_change_request.activation_key
# Expected subject of the email
self.email_subject = "Email Change Confirmation for {platform_name}".format(
platform_name=settings.PLATFORM_NAME
)
# Text fragments we expect in the body of the confirmation email
self.email_fragments = [
"This is to confirm that you changed the e-mail associated with {platform_name}"
" from {old_email} to {new_email}. If you did not make this request, please contact us immediately."
" Contact information is listed at:".format(
platform_name=settings.PLATFORM_NAME,
old_email=self.user.email,
new_email=PendingEmailChange.objects.get(activation_key=self.key).new_email
),
"We keep a log of old e-mails, so if this request was unintentional, we can investigate."
]
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.start_cache_isolation()
@classmethod
def tearDownClass(cls):
cls.end_cache_isolation()
super().tearDownClass()
def assertRolledBack(self):
"""
Assert that no changes to user, profile, or pending email have been made to the db
"""
assert self.user.email == User.objects.get(username=self.user.username).email
assert self.profile.meta == UserProfile.objects.get(user=self.user).meta
assert PendingEmailChange.objects.count() == 1
def assertFailedBeforeEmailing(self):
"""
Assert that the function failed before emailing a user
"""
self.assertRolledBack()
assert len(mail.outbox) == 0
def check_confirm_email_change(self, expected_template, expected_context):
"""
Call `confirm_email_change` and assert that the content was generated as expected
`expected_template`: The name of the template that should have been used
to generate the content
`expected_context`: The context dictionary that should have been used to
generate the content
"""
response = confirm_email_change(self.request, self.key)
assert response.status_code == 200
assert mock_render_to_response(expected_template, expected_context).content.decode('utf-8') \
== response.content.decode('utf-8')
def assertChangeEmailSent(self, test_body_type):
"""
Assert that the correct email was sent to confirm an email change, the same
email contents should be sent to both old and new addresses
"""
self.check_confirm_email_change('email_change_successful.html', {
'old_email': self.user.email,
'new_email': self.pending_change_request.new_email
})
# Must have two items in outbox: one for old email, another for new email
assert len(mail.outbox) == 2
use_https = self.request.is_secure()
if settings.FEATURES['ENABLE_MKTG_SITE']:
contact_link = marketing_link('CONTACT')
else:
contact_link = '{protocol}://{site}{link}'.format(
protocol='https' if use_https else 'http',
site=settings.SITE_NAME,
link=reverse('contact'),
)
# Verifying contents
for msg in mail.outbox:
assert msg.subject == self.email_subject
body_text = {
'plain_text': msg.body,
'html': msg.alternatives[0][0]
}
assert test_body_type in body_text
body_to_be_tested = body_text[test_body_type]
for fragment in self.email_fragments:
assert fragment in body_to_be_tested
assert contact_link in body_to_be_tested
def test_not_pending(self):
self.key = 'not_a_key'
self.check_confirm_email_change('invalid_email_key.html', {})
self.assertFailedBeforeEmailing()
def test_duplicate_email(self):
UserFactory.create(email=self.pending_change_request.new_email)
self.check_confirm_email_change('email_exists.html', {})
self.assertFailedBeforeEmailing()
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
@patch('common.djangoapps.student.views.management.ace')
def test_old_email_fails(self, ace_mail):
ace_mail.send.side_effect = [Exception, None]
self.check_confirm_email_change('email_change_failed.html', {
'email': self.user.email,
})
assert ace_mail.send.call_count == 1
self.assertRolledBack()
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
@patch('common.djangoapps.student.views.management.ace')
def test_new_email_fails(self, ace_mail):
ace_mail.send.side_effect = [None, Exception]
self.check_confirm_email_change('email_change_failed.html', {
'email': self.pending_change_request.new_email
})
assert ace_mail.send.call_count == 2
self.assertRolledBack()
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
@override_settings(MKTG_URLS={'ROOT': 'https://dummy-root', 'CONTACT': '/help/contact-us'})
@ddt.data(
('plain_text', False),
('plain_text', True),
('html', False),
('html', True)
)
@ddt.unpack
def test_successful_email_change(self, test_body_type, test_marketing_enabled):
with patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': test_marketing_enabled}):
self.assertChangeEmailSent(test_body_type)
meta = json.loads(UserProfile.objects.get(user=self.user).meta)
assert 'old_emails' in meta
assert self.user.email == meta['old_emails'][0][0]
assert self.pending_change_request.new_email == User.objects.get(username=self.user.username).email
assert PendingEmailChange.objects.count() == 0
@patch('common.djangoapps.student.views.PendingEmailChange.objects.get', Mock(side_effect=TestException))
def test_always_rollback(self):
connection = transaction.get_connection()
with patch.object(connection, 'rollback', wraps=connection.rollback) as mock_rollback:
with pytest.raises(TestException):
confirm_email_change(self.request, self.key)
mock_rollback.assert_called_with()
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
class SecondaryEmailChangeRequestTests(EventTestMixin, EmailTemplateTagMixin, CacheIsolationTestCase):
"""
Test changing a user's email address
"""
def setUp(self, tracker='common.djangoapps.student.views.management.tracker'):
super().setUp(tracker)
self.user = UserFactory.create()
self.new_secondary_email = 'new.secondary.email@edx.org'
self.req_factory = RequestFactory()
self.request = self.req_factory.post('unused_url', data={
'password': 'test',
'new_email': self.new_secondary_email
})
self.request.user = self.user
self.user.email_user = Mock()
def do_email_validation(self, email):
"""
Executes validate_new_secondary_email, returning any resulting error message.
"""
try:
validate_new_email(self.request.user, email)
except ValueError as err:
return str(err)
def do_secondary_email_change(self, user, email, activation_key=None):
"""
Executes do_secondary_email_change_request, returning any resulting error message.
"""
with patch('crum.get_current_request', return_value=self.fake_request):
do_email_change_request(
user=user,
new_email=email,
activation_key=activation_key,
secondary_email_change_request=True
)
def assertFailedRequest(self, response_data, expected_error):
"""
Assert that `response_data` indicates a failed request that returns `expected_error`
"""
assert not response_data['success']
assert expected_error == response_data['error']
assert not self.user.email_user.called
def test_invalid_emails(self):
"""
Assert the expected error message from the email validation method for an invalid
(improperly formatted) email address.
"""
for email in ('bad_email', 'bad_email@', '@bad_email'):
assert self.do_email_validation(email) == 'Valid e-mail address required.'
@patch('django.core.mail.EmailMultiAlternatives.send')
def test_email_failure(self, send_mail):
"""
Test the return value if sending the email for the user to click fails.
"""
send_mail.side_effect = [Exception, None]
with self.assertRaisesRegex(ValueError, 'Unable to send email activation link. Please try again later.'):
self.do_secondary_email_change(self.user, "valid@email.com")
self.assert_no_events_were_emitted()
def test_email_success(self):
"""
Test email was sent if no errors encountered.
"""
new_email = "valid@example.com"
registration_key = "test-registration-key"
self.do_secondary_email_change(self.user, new_email, registration_key)
self._assert_email(
subject='Confirm your recovery email for édX',
body_fragments=[
'You\'ve registered this recovery email address for édX.',
'If you set this email address, click "confirm email."',
'If you didn\'t request this change, you can disregard this email.',
f'http://edx.org/activate_secondary_email/{registration_key}',
],
)
def _assert_email(self, subject, body_fragments):
"""
Verify that the email was sent.
"""
assert len(mail.outbox) == 1
assert len(body_fragments) > 1, 'Should provide at least two body fragments'
message = mail.outbox[0]
text = message.body
html = message.alternatives[0][0]
assert message.subject == subject
for fragment in body_fragments:
assert fragment in text
assert escape(fragment) in html