Files
edx-platform/common/djangoapps/student/tests/test_email.py
Justin Hynes 426ee163bc revert: add brand_color variable for the email templates (#33421)"
This reverts commit 4ec70eb98b.

This commit introduced a new setting (`brand_color`) that does not appear to be set and is causing issues with account deletion and other parts of the courseware.

Reverting until we can understand the change better.
2024-01-24 18:47:02 +00:00

721 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# lint-amnesty, pylint: disable=missing-module-docstring
import json
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 testfixtures import LogCapture
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 openedx.core.djangoapps.ace_common.tests.mixins import EmailTemplateTagMixin
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangolib.testing.utils import CacheIsolationMixin, CacheIsolationTestCase, skip_unless_lms
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
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
@skip_unless_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)
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': 'Password1234',
'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.send_activation_email.delay')
def test_activation_email_exception(self, mock_task):
"""
Test that if an exception occurs within send_activation_email, it is logged
and not raised.
"""
mock_task.side_effect = Exception('BOOM!')
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):
with LogCapture() as logger:
inactive_user_view(request)
assert mock_task.called is True, 'method should have been called'
logger.check_present(
(
'edx.student',
'ERROR',
f'Activation email task failed for user {inactive_user.id}.'
)
)
@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
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True})
@override_settings(ACCOUNT_MICROFRONTEND_URL='http://account-mfe')
@skip_unless_lms
class ProctoringRequirementsEmailTests(EmailTemplateTagMixin, ModuleStoreTestCase):
"""
Test sending of the proctoring requirements email.
"""
# pylint: disable=no-member
def setUp(self):
super().setUp()
self.course = None
self.user = UserFactory()
@ddt.data('course_run_1', 'matt\'s course', 'matts run')
def test_send_proctoring_requirements_email(self, course_run_name):
self.course = CourseFactory(
display_name=course_run_name,
enable_proctored_exams=True
)
context = generate_proctoring_requirements_email_context(self.user, self.course.id)
send_proctoring_requirements_email(context)
self._assert_email()
def test_send_proctoring_requirements_email_honor(self):
self.course = CourseFactory(
display_name='honor code on course',
enable_proctored_exams=True
)
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}"
appears = self._get_fragments()
for fragment in appears:
self.assertIn(fragment, text)
self.assertIn(fragment, html)
def _get_fragments(self):
"""
Provide a tuple of string[]s that should be (in, not_in) the email
"""
course_block = modulestore().get_course(self.course.id)
proctoring_provider = capwords(course_block.proctoring_provider.replace('_', ' '))
fragments = [
(
"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,
escape(
"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', ''),
]
return fragments
@skip_unless_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': 'Password1234',
'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()
@skip_unless_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()
@skip_unless_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()
@skip_unless_lms
@override_settings(MKTG_URLS={'ROOT': 'https://dummy-root', 'CONTACT': '/help/contact-us'})
@patch('common.djangoapps.student.signals.signals.USER_EMAIL_CHANGED.send')
@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, mock_email_change_signal):
with patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': test_marketing_enabled}):
self.assertChangeEmailSent(test_body_type)
assert mock_email_change_signal.called
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()
@skip_unless_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': 'Password1234',
'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