test_email.py 16.9 KB
Newer Older
1

2 3
import json
import django.db
4
import unittest
5 6

from student.tests.factories import UserFactory, RegistrationFactory, PendingEmailChangeFactory
7 8 9
from student.views import (
    reactivation_email_for_user, change_email_request, do_email_change_request, confirm_email_change
)
10
from student.models import UserProfile, PendingEmailChange
11 12
from django.core.urlresolvers import reverse
from django.core import mail
13
from django.contrib.auth.models import User, AnonymousUser
14 15 16 17 18
from django.test import TestCase, TransactionTestCase
from django.test.client import RequestFactory
from mock import Mock, patch
from django.http import Http404, HttpResponse
from django.conf import settings
19
from edxmako.shortcuts import render_to_string
20
from edxmako.tests import mako_middleware_process_request
21
from util.request import safe_get_host
22 23 24 25 26 27 28 29 30 31 32 33 34 35


class TestException(Exception):
    """Exception used for testing that nothing will catch explicitly"""
    pass


def mock_render_to_string(template_name, context):
    """Return a string that encodes template_name and context"""
    return str((template_name, sorted(context.iteritems())))


def mock_render_to_response(template_name, context):
    """Return an HttpResponse with content that encodes template_name and context"""
36 37 38
    # View confirm_email_change uses @transaction.commit_manually.
    # This simulates any db access in the templates.
    UserProfile.objects.exists()
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
    return HttpResponse(mock_render_to_string(template_name, context))


class EmailTestMixin(object):
    """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),
            settings.DEFAULT_FROM_EMAIL
        )

61 62 63 64 65
    def append_allowed_hosts(self, hostname):
        """ Append hostname to settings.ALLOWED_HOSTS """
        settings.ALLOWED_HOSTS.append(hostname)
        self.addCleanup(settings.ALLOWED_HOSTS.pop)

66

67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class ActivationEmailTests(TestCase):
    """Test sending of the activation email. """

    ACTIVATION_SUBJECT = "Activate Your edX Account"

    # Text fragments we expect in the body of an email
    # sent from an OpenEdX installation.
    OPENEDX_FRAGMENTS = [
        "Thank you for signing up for {platform}.".format(platform=settings.PLATFORM_NAME),
        "http://edx.org/activate/",
        (
            "if you require assistance, check the help section of the "
            "{platform} website".format(platform=settings.PLATFORM_NAME)
        )
    ]

    # Text fragments we expect in the body of an email
    # sent from an EdX-controlled domain.
    EDX_DOMAIN_FRAGMENTS = [
        "Thank you for signing up for {platform}".format(platform=settings.PLATFORM_NAME),
        "http://edx.org/activate/",
        "https://www.edx.org/contact-us",
        "This email was automatically sent by edx.org"
    ]

    def setUp(self):
        super(ActivationEmailTests, self).setUp()

    def test_activation_email(self):
        self._create_account()
        self._assert_activation_email(self.ACTIVATION_SUBJECT, self.OPENEDX_FRAGMENTS)

    @patch.dict(settings.FEATURES, {'IS_EDX_DOMAIN': True})
    def test_activation_email_edx_domain(self):
        self._create_account()
        self._assert_activation_email(self.ACTIVATION_SUBJECT, self.EDX_DOMAIN_FRAGMENTS)

    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)
        self.assertEqual(
            resp.status_code, 200,
            msg=u"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):
        """Verify that the activation email was sent. """
        self.assertEqual(len(mail.outbox), 1)
        msg = mail.outbox[0]
        self.assertEqual(msg.subject, subject)
        for fragment in body_fragments:
            self.assertIn(fragment, msg.body)


134 135 136 137 138 139 140
@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
@patch('django.contrib.auth.models.User.email_user')
class ReactivationEmailTests(EmailTestMixin, TestCase):
    """Test sending a reactivation email to a user"""

    def setUp(self):
        self.user = UserFactory.create()
141
        self.unregisteredUser = UserFactory.create()
142 143
        self.registration = RegistrationFactory.create(user=self.user)

144 145 146 147 148 149
    def reactivation_email(self, user):
        """
        Send the reactivation email to the specified user,
        and return the response as json data.
        """
        return json.loads(reactivation_email_for_user(user).content)
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165

    def assertReactivateEmailSent(self, email_user):
        """Assert that the correct reactivation email has been sent"""
        context = {
            'name': self.user.profile.name,
            'key': self.registration.activation_key
        }

        self.assertEmailUser(
            email_user,
            'emails/activation_email_subject.txt',
            context,
            'emails/activation_email.txt',
            context
        )

166 167
        # Thorough tests for safe_get_host are elsewhere; here we just want a quick URL sanity check
        request = RequestFactory().post('unused_url')
168
        request.user = self.user
169 170 171
        request.META['HTTP_HOST'] = "aGenericValidHostName"
        self.append_allowed_hosts("aGenericValidHostName")

172
        mako_middleware_process_request(request)
173 174 175 176 177
        body = render_to_string('emails/activation_email.txt', context)
        host = safe_get_host(request)

        self.assertIn(host, body)

178 179
    def test_reactivation_email_failure(self, email_user):
        self.user.email_user.side_effect = Exception
180
        response_data = self.reactivation_email(self.user)
181 182 183 184

        self.assertReactivateEmailSent(email_user)
        self.assertFalse(response_data['success'])

185 186 187 188 189 190 191 192 193
    def test_reactivation_for_unregistered_user(self, email_user):
        """
        Test that trying to send a reactivation email to an unregistered
        user fails without throwing a 500 error.
        """
        response_data = self.reactivation_email(self.unregisteredUser)

        self.assertFalse(response_data['success'])

194
    def test_reactivation_email_success(self, email_user):
195
        response_data = self.reactivation_email(self.user)
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232

        self.assertReactivateEmailSent(email_user)
        self.assertTrue(response_data['success'])


class EmailChangeRequestTests(TestCase):
    """Test changing a user's email address"""

    def setUp(self):
        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 run_request(self, request=None):
        """Execute request and return result parsed as json

        If request isn't passed in, use self.request instead
        """
        if request is None:
            request = self.request

        response = change_email_request(self.request)
        return json.loads(response.content)

    def assertFailedRequest(self, response_data, expected_error):
        """Assert that `response_data` indicates a failed request that returns `expected_error`"""
        self.assertFalse(response_data['success'])
        self.assertEquals(expected_error, response_data['error'])
        self.assertFalse(self.user.email_user.called)

    def test_unauthenticated(self):
233 234
        self.request.user = AnonymousUser()
        self.request.user.email_user = Mock()
235 236
        with self.assertRaises(Http404):
            change_email_request(self.request)
237
        self.assertFalse(self.request.user.email_user.called)
238 239 240 241 242 243 244 245 246 247

    def test_invalid_password(self):
        self.request.POST['password'] = 'wrong'
        self.assertFailedRequest(self.run_request(), 'Invalid password')

    def test_invalid_emails(self):
        for email in ('bad_email', 'bad_email@', '@bad_email'):
            self.request.POST['new_email'] = email
            self.assertFailedRequest(self.run_request(), 'Valid e-mail address required.')

248 249 250 251 252
    def test_change_email_to_existing_value(self):
        """ Test the error message if user attempts to change email to the existing value. """
        self.request.POST['new_email'] = self.user.email
        self.assertFailedRequest(self.run_request(), 'Old email is the same as the new email.')

253 254 255 256 257 258 259 260 261 262 263 264 265 266
    def check_duplicate_email(self, email):
        """Test that a request to change a users email to `email` fails"""
        request = self.req_factory.post('unused_url', data={
            'new_email': email,
            'password': 'test',
        })
        request.user = self.user
        self.assertFailedRequest(self.run_request(request), 'An account with this e-mail already exists.')

    def test_duplicate_email(self):
        UserFactory.create(email=self.new_email)
        self.check_duplicate_email(self.new_email)

    def test_capitalized_duplicate_email(self):
267
        """Test that we check for email addresses in a case insensitive way"""
268 269 270
        UserFactory.create(email=self.new_email)
        self.check_duplicate_email(self.new_email.capitalize())

271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297
    @patch('django.core.mail.send_mail')
    @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
    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]
        self.request.POST['new_email'] = "valid@email.com"
        self.assertFailedRequest(self.run_request(), 'Unable to send email activation link. Please try again later.')

    @patch('django.core.mail.send_mail')
    @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
    def test_email_success(self, send_mail):
        """ Test email was sent if no errors encountered. """
        old_email = self.user.email
        new_email = "valid@example.com"
        registration_key = "test registration key"
        do_email_change_request(self.user, new_email, registration_key)
        context = {
            'key': registration_key,
            'old_email': old_email,
            'new_email': new_email
        }
        send_mail.assert_called_with(
            mock_render_to_string('emails/email_change_subject.txt', context),
            mock_render_to_string('emails/email_change.txt', context),
            settings.DEFAULT_FROM_EMAIL,
            [new_email]
        )
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353


@patch('django.contrib.auth.models.User.email_user')
@patch('student.views.render_to_response', Mock(side_effect=mock_render_to_response, autospec=True))
@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
class EmailChangeConfirmationTests(EmailTestMixin, TransactionTestCase):
    """Test that confirmation of email change requests function even in the face of exceptions thrown while sending email"""
    def setUp(self):
        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.user.email_user = Mock()
        self.pending_change_request = PendingEmailChangeFactory.create(user=self.user)
        self.key = self.pending_change_request.activation_key

    def assertRolledBack(self):
        """Assert that no changes to user, profile, or pending email have been made to the db"""
        self.assertEquals(self.user.email, User.objects.get(username=self.user.username).email)
        self.assertEquals(self.profile.meta, UserProfile.objects.get(user=self.user).meta)
        self.assertEquals(1, PendingEmailChange.objects.count())

    def assertFailedBeforeEmailing(self, email_user):
        """Assert that the function failed before emailing a user"""
        self.assertRolledBack()
        self.assertFalse(email_user.called)

    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)
        self.assertEquals(
            mock_render_to_response(expected_template, expected_context).content,
            response.content
        )

    def assertChangeEmailSent(self, email_user):
        """Assert that the correct email was sent to confirm an email change"""
        context = {
            'old_email': self.user.email,
            'new_email': self.pending_change_request.new_email,
        }
        self.assertEmailUser(
            email_user,
            'emails/email_change_subject.txt',
            context,
            'emails/confirm_email_change.txt',
            context
        )

354 355
        # Thorough tests for safe_get_host are elsewhere; here we just want a quick URL sanity check
        request = RequestFactory().post('unused_url')
356
        request.user = self.user
357 358 359
        request.META['HTTP_HOST'] = "aGenericValidHostName"
        self.append_allowed_hosts("aGenericValidHostName")

360
        mako_middleware_process_request(request)
361 362 363 364 365
        body = render_to_string('emails/confirm_email_change.txt', context)
        url = safe_get_host(request)

        self.assertIn(url, body)

366 367 368 369 370 371 372 373 374 375
    def test_not_pending(self, email_user):
        self.key = 'not_a_key'
        self.check_confirm_email_change('invalid_email_key.html', {})
        self.assertFailedBeforeEmailing(email_user)

    def test_duplicate_email(self, email_user):
        UserFactory.create(email=self.pending_change_request.new_email)
        self.check_confirm_email_change('email_exists.html', {})
        self.assertFailedBeforeEmailing(email_user)

376
    @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
377 378 379 380 381 382 383 384
    def test_old_email_fails(self, email_user):
        email_user.side_effect = [Exception, None]
        self.check_confirm_email_change('email_change_failed.html', {
            'email': self.user.email,
        })
        self.assertRolledBack()
        self.assertChangeEmailSent(email_user)

385
    @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
386 387 388 389 390 391 392 393
    def test_new_email_fails(self, email_user):
        email_user.side_effect = [None, Exception]
        self.check_confirm_email_change('email_change_failed.html', {
            'email': self.pending_change_request.new_email
        })
        self.assertRolledBack()
        self.assertChangeEmailSent(email_user)

394
    @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416
    def test_successful_email_change(self, email_user):
        self.check_confirm_email_change('email_change_successful.html', {
            'old_email': self.user.email,
            'new_email': self.pending_change_request.new_email
        })
        self.assertChangeEmailSent(email_user)
        meta = json.loads(UserProfile.objects.get(user=self.user).meta)
        self.assertIn('old_emails', meta)
        self.assertEquals(self.user.email, meta['old_emails'][0][0])
        self.assertEquals(
            self.pending_change_request.new_email,
            User.objects.get(username=self.user.username).email
        )
        self.assertEquals(0, PendingEmailChange.objects.count())

    @patch('student.views.PendingEmailChange.objects.get', Mock(side_effect=TestException))
    @patch('student.views.transaction.rollback', wraps=django.db.transaction.rollback)
    def test_always_rollback(self, rollback, _email_user):
        with self.assertRaises(TestException):
            confirm_email_change(self.request, self.key)

        rollback.assert_called_with()