From c9323abd0e0952d8c9d35ea0e1f47bd380d9aae9 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 4 Nov 2019 13:21:18 -0500 Subject: [PATCH] Move password reset logic and code to user_authn. --- common/djangoapps/student/forms.py | 35 +----- common/djangoapps/student/message_types.py | 7 -- common/djangoapps/student/views/management.py | 3 +- openedx/core/djangoapps/user_api/api.py | 45 ------- .../core/djangoapps/user_api/legacy_urls.py | 5 - .../djangoapps/user_api/tests/test_views.py | 59 --------- openedx/core/djangoapps/user_api/views.py | 13 -- .../djangoapps/user_authn/message_types.py | 15 +++ .../edx_ace/passwordreset/email/body.html | 0 .../edx_ace/passwordreset/email/body.txt | 0 .../edx_ace/passwordreset/email/from_name.txt | 0 .../edx_ace/passwordreset/email/head.html | 0 .../edx_ace/passwordreset/email/subject.txt | 0 .../core/djangoapps/user_authn/urls_common.py | 7 +- .../core/djangoapps/user_authn/views/login.py | 2 +- .../djangoapps/user_authn/views/login_form.py | 2 +- .../user_authn/views/password_reset.py | 114 ++++++++++++++++++ .../views}/tests/test_reset_password.py | 76 +++++++++++- 18 files changed, 209 insertions(+), 174 deletions(-) create mode 100644 openedx/core/djangoapps/user_authn/message_types.py rename {common/templates/student => openedx/core/djangoapps/user_authn/templates/user_authn}/edx_ace/passwordreset/email/body.html (100%) rename {common/templates/student => openedx/core/djangoapps/user_authn/templates/user_authn}/edx_ace/passwordreset/email/body.txt (100%) rename {common/templates/student => openedx/core/djangoapps/user_authn/templates/user_authn}/edx_ace/passwordreset/email/from_name.txt (100%) rename {common/templates/student => openedx/core/djangoapps/user_authn/templates/user_authn}/edx_ace/passwordreset/email/head.html (100%) rename {common/templates/student => openedx/core/djangoapps/user_authn/templates/user_authn}/edx_ace/passwordreset/email/subject.txt (100%) create mode 100644 openedx/core/djangoapps/user_authn/views/password_reset.py rename {common/djangoapps/student => openedx/core/djangoapps/user_authn/views}/tests/test_reset_password.py (87%) diff --git a/common/djangoapps/student/forms.py b/common/djangoapps/student/forms.py index 59264e9626..89aa4204b3 100644 --- a/common/djangoapps/student/forms.py +++ b/common/djangoapps/student/forms.py @@ -28,45 +28,12 @@ from openedx.core.djangoapps.theming.helpers import get_current_site from openedx.core.djangoapps.user_api import accounts as accounts_settings from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled from openedx.core.djangoapps.user_api.preferences.api import get_user_preference +from openedx.core.djangoapps.user_authn.views.password_reset import send_password_reset_email_for_user from student.message_types import AccountRecovery as AccountRecoveryMessage -from student.message_types import PasswordReset from student.models import AccountRecovery, CourseEnrollmentAllowed, email_exists_or_retired from util.password_policy_validators import validate_password -def send_password_reset_email_for_user(user, request, preferred_email=None): - """ - Send out a password reset email for the given user. - - Arguments: - user (User): Django User object - request (HttpRequest): Django request object - preferred_email (str): Send email to this address if present, otherwise fallback to user's email address. - """ - site = get_current_site() - message_context = get_base_template_context(site) - message_context.update({ - 'request': request, # Used by google_analytics_tracking_pixel - # TODO: This overrides `platform_name` from `get_base_template_context` to make the tests passes - 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), - 'reset_link': '{protocol}://{site}{link}?track=pwreset'.format( - protocol='https' if request.is_secure() else 'http', - site=configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME), - link=reverse('password_reset_confirm', kwargs={ - 'uidb36': int_to_base36(user.id), - 'token': default_token_generator.make_token(user), - }), - ) - }) - - msg = PasswordReset().personalize( - recipient=Recipient(user.username, preferred_email or user.email), - language=get_user_preference(user, LANGUAGE_KEY), - user_context=message_context, - ) - ace.send(msg) - - def send_account_recovery_email_for_user(user, request, email=None): """ Send out a account recovery email for the given user. diff --git a/common/djangoapps/student/message_types.py b/common/djangoapps/student/message_types.py index 331197f059..4e64e7d601 100644 --- a/common/djangoapps/student/message_types.py +++ b/common/djangoapps/student/message_types.py @@ -7,13 +7,6 @@ from __future__ import absolute_import from openedx.core.djangoapps.ace_common.message import BaseMessageType -class PasswordReset(BaseMessageType): - def __init__(self, *args, **kwargs): - super(PasswordReset, self).__init__(*args, **kwargs) - - self.options['transactional'] = True - - class AccountRecovery(BaseMessageType): def __init__(self, *args, **kwargs): super(AccountRecovery, self).__init__(*args, **kwargs) diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index cf8cec36ce..1da1390752 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -63,10 +63,11 @@ from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRI from openedx.core.djangoapps.user_api.errors import UserAPIInternalError, UserNotFound from openedx.core.djangoapps.user_api.models import UserRetirementRequest from openedx.core.djangoapps.user_api.preferences import api as preferences_api +from openedx.core.djangoapps.user_authn.message_types import PasswordReset from openedx.core.djangolib.markup import HTML, Text from student.forms import AccountCreationForm, PasswordResetFormNoActive from student.helpers import DISABLE_UNENROLL_CERT_STATES, cert_info, generate_activation_email_context -from student.message_types import EmailChange, EmailChangeConfirmation, PasswordReset, RecoveryEmailCreate +from student.message_types import EmailChange, EmailChangeConfirmation, RecoveryEmailCreate from student.models import ( AccountRecovery, CourseEnrollment, diff --git a/openedx/core/djangoapps/user_api/api.py b/openedx/core/djangoapps/user_api/api.py index 4f342516f2..cba96e7458 100644 --- a/openedx/core/djangoapps/user_api/api.py +++ b/openedx/core/djangoapps/user_api/api.py @@ -28,51 +28,6 @@ from util.password_policy_validators import ( ) -def get_password_reset_form(): - """Return a description of the password reset form. - - This decouples clients from the API definition: - if the API decides to modify the form, clients won't need - to be updated. - - See `user_api.helpers.FormDescription` for examples - of the JSON-encoded form description. - - Returns: - HttpResponse - - """ - form_desc = FormDescription("post", reverse("password_change_request")) - - # Translators: This label appears above a field on the password reset - # form meant to hold the user's email address. - email_label = _(u"Email") - - # Translators: This example email address is used as a placeholder in - # a field on the password reset form meant to hold the user's email address. - email_placeholder = _(u"username@domain.com") - - # Translators: These instructions appear on the password reset form, - # immediately below a field meant to hold the user's email address. - email_instructions = _(u"The email address you used to register with {platform_name}").format( - platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME) - ) - - form_desc.add_field( - "email", - field_type="email", - label=email_label, - placeholder=email_placeholder, - instructions=email_instructions, - restrictions={ - "min_length": accounts.EMAIL_MIN_LENGTH, - "max_length": accounts.EMAIL_MAX_LENGTH, - } - ) - - return form_desc - - def get_login_session_form(request): """Return a description of the login form. diff --git a/openedx/core/djangoapps/user_api/legacy_urls.py b/openedx/core/djangoapps/user_api/legacy_urls.py index a2502b095a..bcb1c5bdaf 100644 --- a/openedx/core/djangoapps/user_api/legacy_urls.py +++ b/openedx/core/djangoapps/user_api/legacy_urls.py @@ -36,8 +36,3 @@ urlpatterns = [ user_api_views.CountryTimeZoneListView.as_view(), ), ] - -urlpatterns += [ - url(r'^v1/account/password_reset/$', user_api_views.PasswordResetView.as_view(), - name="user_api_password_reset"), -] diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index cadca232bb..ffdf91bc6d 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -564,65 +564,6 @@ class PreferenceUsersListViewTest(UserApiTestCase): self.assertEqual(len(set(all_user_uris)), 2) -@ddt.ddt -@skip_unless_lms -class PasswordResetViewTest(UserAPITestCase): - """Tests of the user API's password reset endpoint. """ - - def setUp(self): - super(PasswordResetViewTest, self).setUp() - self.url = reverse("user_api_password_reset") - - @ddt.data("get", "post") - def test_auth_disabled(self, method): - self.assertAuthDisabled(method, self.url) - - def test_allowed_methods(self): - self.assertAllowedMethods(self.url, ["GET", "HEAD", "OPTIONS"]) - - def test_put_not_allowed(self): - response = self.client.put(self.url) - self.assertHttpMethodNotAllowed(response) - - def test_delete_not_allowed(self): - response = self.client.delete(self.url) - self.assertHttpMethodNotAllowed(response) - - def test_patch_not_allowed(self): - response = self.client.patch(self.url) - self.assertHttpMethodNotAllowed(response) - - def test_password_reset_form(self): - # Retrieve the password reset form - response = self.client.get(self.url, content_type="application/json") - self.assertHttpOK(response) - - # Verify that the form description matches what we expect - form_desc = json.loads(response.content.decode('utf-8')) - self.assertEqual(form_desc["method"], "post") - self.assertEqual(form_desc["submit_url"], reverse("password_change_request")) - self.assertEqual(form_desc["fields"], [ - { - "name": "email", - "defaultValue": "", - "type": "email", - "required": True, - "label": "Email", - "placeholder": "username@domain.com", - "instructions": u"The email address you used to register with {platform_name}".format( - platform_name=settings.PLATFORM_NAME - ), - "restrictions": { - "min_length": EMAIL_MIN_LENGTH, - "max_length": EMAIL_MAX_LENGTH - }, - "errorMessages": {}, - "supplementalText": "", - "supplementalLink": "", - } - ]) - - @ddt.ddt @skip_unless_lms class UpdateEmailOptInTestCase(UserAPITestCase, SharedModuleStoreTestCase): diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 2ae44709e7..60b5fe1f3e 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -25,7 +25,6 @@ from openedx.core.djangoapps.user_api import accounts from openedx.core.djangoapps.user_api.accounts.api import check_account_exists from openedx.core.djangoapps.user_api.api import ( get_login_session_form, - get_password_reset_form ) from openedx.core.lib.api.view_utils import require_post_params from openedx.core.djangoapps.user_api.models import UserPreference @@ -40,18 +39,6 @@ from student.helpers import AccountValidationError from util.json_request import JsonResponse -class PasswordResetView(APIView): - """HTTP end-point for GETting a description of the password reset form. """ - - # This end-point is available to anonymous users, - # so do not require authentication. - authentication_classes = [] - - @method_decorator(ensure_csrf_cookie) - def get(self, request): - return HttpResponse(get_password_reset_form().to_json(), content_type="application/json") - - class UserViewSet(viewsets.ReadOnlyModelViewSet): """ DRF class for interacting with the User ORM object diff --git a/openedx/core/djangoapps/user_authn/message_types.py b/openedx/core/djangoapps/user_authn/message_types.py new file mode 100644 index 0000000000..ec2fa667c4 --- /dev/null +++ b/openedx/core/djangoapps/user_authn/message_types.py @@ -0,0 +1,15 @@ +""" +ACE message types for user_authn-related emails. +""" + +from __future__ import absolute_import + +from openedx.core.djangoapps.ace_common.message import BaseMessageType + + +class PasswordReset(BaseMessageType): + def __init__(self, *args, **kwargs): + super(PasswordReset, self).__init__(*args, **kwargs) + + # pylint: disable=unsupported-assignment-operation + self.options['transactional'] = True diff --git a/common/templates/student/edx_ace/passwordreset/email/body.html b/openedx/core/djangoapps/user_authn/templates/user_authn/edx_ace/passwordreset/email/body.html similarity index 100% rename from common/templates/student/edx_ace/passwordreset/email/body.html rename to openedx/core/djangoapps/user_authn/templates/user_authn/edx_ace/passwordreset/email/body.html diff --git a/common/templates/student/edx_ace/passwordreset/email/body.txt b/openedx/core/djangoapps/user_authn/templates/user_authn/edx_ace/passwordreset/email/body.txt similarity index 100% rename from common/templates/student/edx_ace/passwordreset/email/body.txt rename to openedx/core/djangoapps/user_authn/templates/user_authn/edx_ace/passwordreset/email/body.txt diff --git a/common/templates/student/edx_ace/passwordreset/email/from_name.txt b/openedx/core/djangoapps/user_authn/templates/user_authn/edx_ace/passwordreset/email/from_name.txt similarity index 100% rename from common/templates/student/edx_ace/passwordreset/email/from_name.txt rename to openedx/core/djangoapps/user_authn/templates/user_authn/edx_ace/passwordreset/email/from_name.txt diff --git a/common/templates/student/edx_ace/passwordreset/email/head.html b/openedx/core/djangoapps/user_authn/templates/user_authn/edx_ace/passwordreset/email/head.html similarity index 100% rename from common/templates/student/edx_ace/passwordreset/email/head.html rename to openedx/core/djangoapps/user_authn/templates/user_authn/edx_ace/passwordreset/email/head.html diff --git a/common/templates/student/edx_ace/passwordreset/email/subject.txt b/openedx/core/djangoapps/user_authn/templates/user_authn/edx_ace/passwordreset/email/subject.txt similarity index 100% rename from common/templates/student/edx_ace/passwordreset/email/subject.txt rename to openedx/core/djangoapps/user_authn/templates/user_authn/edx_ace/passwordreset/email/subject.txt diff --git a/openedx/core/djangoapps/user_authn/urls_common.py b/openedx/core/djangoapps/user_authn/urls_common.py index 0b36b44b49..45009c884b 100644 --- a/openedx/core/djangoapps/user_authn/urls_common.py +++ b/openedx/core/djangoapps/user_authn/urls_common.py @@ -11,7 +11,7 @@ from __future__ import absolute_import from django.conf import settings from django.conf.urls import url -from .views import auto_auth, login, logout, register +from .views import auto_auth, login, logout, password_reset, register urlpatterns = [ @@ -19,7 +19,6 @@ urlpatterns = [ url(r'^create_account$', register.RegistrationView.as_view(), name='create_account'), url(r'^user_api/v1/account/registration/$', register.RegistrationView.as_view(), name="user_api_registration"), - # Login url(r'^login_post$', login.login_user, name='login_post'), url(r'^login_ajax$', login.login_user, name="login"), @@ -31,6 +30,10 @@ urlpatterns = [ url(r'^login_refresh$', login.login_refresh, name="login_refresh"), url(r'^logout$', logout.LogoutView.as_view(), name='logout'), + + url(r'^v1/account/password_reset/$', password_reset.PasswordResetView.as_view(), + name="user_api_password_reset"), + ] diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py index 442934a122..fb5895f79d 100644 --- a/openedx/core/djangoapps/user_authn/views/login.py +++ b/openedx/core/djangoapps/user_authn/views/login.py @@ -32,9 +32,9 @@ from openedx.core.djangoapps.user_api.api import get_login_session_form from openedx.core.djangoapps.user_authn.cookies import refresh_jwt_cookies, set_logged_in_cookies from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError from openedx.core.djangoapps.util.user_messages import PageLevelMessages +from openedx.core.djangoapps.user_authn.views.password_reset import send_password_reset_email_for_user from openedx.core.djangolib.markup import HTML, Text from openedx.core.lib.api.view_utils import require_post_params -from student.forms import send_password_reset_email_for_user from student.models import LoginFailures from student.views import send_reactivation_email_for_user from third_party_auth import pipeline, provider diff --git a/openedx/core/djangoapps/user_authn/views/login_form.py b/openedx/core/djangoapps/user_authn/views/login_form.py index b19d7d2c4f..1843297232 100644 --- a/openedx/core/djangoapps/user_authn/views/login_form.py +++ b/openedx/core/djangoapps/user_authn/views/login_form.py @@ -20,10 +20,10 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_ from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled from openedx.core.djangoapps.user_api.api import ( get_login_session_form, - get_password_reset_form ) from openedx.core.djangoapps.user_authn.cookies import are_logged_in_cookies_set from openedx.core.djangoapps.user_authn.views.registration_form import RegistrationFormFactory +from openedx.core.djangoapps.user_authn.views.password_reset import get_password_reset_form from openedx.features.enterprise_support.api import enterprise_customer_for_request from openedx.features.enterprise_support.utils import ( handle_enterprise_cookies_for_logistration, diff --git a/openedx/core/djangoapps/user_authn/views/password_reset.py b/openedx/core/djangoapps/user_authn/views/password_reset.py new file mode 100644 index 0000000000..8178478693 --- /dev/null +++ b/openedx/core/djangoapps/user_authn/views/password_reset.py @@ -0,0 +1,114 @@ +""" Handles password resets logic. """ + +from django.conf import settings +from django.contrib.auth.tokens import default_token_generator +from django.http import HttpResponse +from django.utils.decorators import method_decorator +from django.utils.http import int_to_base36 +from django.utils.translation import ugettext_lazy as _ +from django.urls import reverse +from django.views.decorators.csrf import ensure_csrf_cookie + +from edx_ace import ace +from edx_ace.recipient import Recipient +from rest_framework.views import APIView + +from openedx.core.djangoapps.ace_common.template_context import get_base_template_context +from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.theming.helpers import get_current_site +from openedx.core.djangoapps.user_api import accounts +from openedx.core.djangoapps.user_api.helpers import FormDescription +from openedx.core.djangoapps.user_api.preferences.api import get_user_preference +from openedx.core.djangoapps.user_authn.message_types import PasswordReset + + +def get_password_reset_form(): + """Return a description of the password reset form. + + This decouples clients from the API definition: + if the API decides to modify the form, clients won't need + to be updated. + + See `user_api.helpers.FormDescription` for examples + of the JSON-encoded form description. + + Returns: + HttpResponse + + """ + form_desc = FormDescription("post", reverse("password_change_request")) + + # Translators: This label appears above a field on the password reset + # form meant to hold the user's email address. + email_label = _(u"Email") + + # Translators: This example email address is used as a placeholder in + # a field on the password reset form meant to hold the user's email address. + email_placeholder = _(u"username@domain.com") + + # Translators: These instructions appear on the password reset form, + # immediately below a field meant to hold the user's email address. + # pylint: disable=no-member + email_instructions = _(u"The email address you used to register with {platform_name}").format( + platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME) + ) + + form_desc.add_field( + "email", + field_type="email", + label=email_label, + placeholder=email_placeholder, + instructions=email_instructions, + restrictions={ + "min_length": accounts.EMAIL_MIN_LENGTH, + "max_length": accounts.EMAIL_MAX_LENGTH, + } + ) + + return form_desc + + +class PasswordResetView(APIView): + """HTTP end-point for GETting a description of the password reset form. """ + + # This end-point is available to anonymous users, + # so do not require authentication. + authentication_classes = [] + + @method_decorator(ensure_csrf_cookie) + def get(self, request): + return HttpResponse(get_password_reset_form().to_json(), content_type="application/json") + + +def send_password_reset_email_for_user(user, request, preferred_email=None): + """ + Send out a password reset email for the given user. + + Arguments: + user (User): Django User object + request (HttpRequest): Django request object + preferred_email (str): Send email to this address if present, otherwise fallback to user's email address. + """ + site = get_current_site() + message_context = get_base_template_context(site) + message_context.update({ + 'request': request, # Used by google_analytics_tracking_pixel + # TODO: This overrides `platform_name` from `get_base_template_context` to make the tests passes + 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), + 'reset_link': '{protocol}://{site}{link}?track=pwreset'.format( + protocol='https' if request.is_secure() else 'http', + site=configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME), + link=reverse('password_reset_confirm', kwargs={ + 'uidb36': int_to_base36(user.id), + 'token': default_token_generator.make_token(user), + }), + ) + }) + + msg = PasswordReset().personalize( + recipient=Recipient(user.username, preferred_email or user.email), + language=get_user_preference(user, LANGUAGE_KEY), + user_context=message_context, + ) + ace.send(msg) diff --git a/common/djangoapps/student/tests/test_reset_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py similarity index 87% rename from common/djangoapps/student/tests/test_reset_password.py rename to openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py index 76b0a4dbac..26b3ab50af 100644 --- a/common/djangoapps/student/tests/test_reset_password.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py @@ -28,17 +28,19 @@ from six.moves import range from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangolib.testing.utils import skip_unless_lms from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle from openedx.core.djangoapps.user_api.models import UserRetirementRequest +from openedx.core.djangoapps.user_api.tests.test_views import UserAPITestCase +from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH, EMAIL_MIN_LENGTH from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from student.tests.factories import UserFactory +from student.tests.test_configuration_overrides import fake_get_value from student.tests.test_email import mock_render_to_string from student.views import SETTING_CHANGE_INITIATED, password_reset, password_reset_confirm_wrapper from util.password_policy_validators import create_validator_config from util.testing import EventTestMixin -from .test_configuration_overrides import fake_get_value - @unittest.skipUnless( settings.ROOT_URLCONF == "lms.urls", @@ -53,7 +55,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): ENABLED_CACHES = ['default'] - def setUp(self): + def setUp(self): # pylint: disable=arguments-differ super(ResetPasswordTests, self).setUp('student.views.management.tracker') self.user = UserFactory.create() self.user.is_active = False @@ -219,12 +221,12 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): password_reset(req) _, msg, _, _ = send_email.call_args[0] - reset_msg = "you requested a password reset for your user account at {}" + reset_msg = u"you requested a password reset for your user account at {}" reset_msg = reset_msg.format(site_name) self.assertIn(reset_msg, msg) - sign_off = "The {} Team".format(platform_name) + sign_off = u"The {} Team".format(platform_name) self.assertIn(sign_off, msg) self.assert_event_emitted( @@ -257,7 +259,9 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): body = bodies[body_type] - reset_msg = "you requested a password reset for your user account at {}".format(fake_get_value('PLATFORM_NAME')) + reset_msg = u"you requested a password reset for your user account at {}".format( + fake_get_value('PLATFORM_NAME') + ) self.assertIn(reset_msg, body) @@ -378,6 +382,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): assert not self.user.is_active def test_password_reset_normalize_password(self): + # pylint: disable=anomalous-unicode-escape-in-string """ Tests that if we provide a not properly normalized password, it is saved using our normalization method of NFKC. @@ -481,3 +486,62 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): reset_request.user = UserFactory.create() self.assertRaises(Http404, password_reset_confirm_wrapper, reset_request, self.uidb36, self.token) + + +@ddt.ddt +@skip_unless_lms +class PasswordResetViewTest(UserAPITestCase): + """Tests of the user API's password reset endpoint. """ + + def setUp(self): + super(PasswordResetViewTest, self).setUp() + self.url = reverse("user_api_password_reset") + + @ddt.data("get", "post") + def test_auth_disabled(self, method): + self.assertAuthDisabled(method, self.url) + + def test_allowed_methods(self): + self.assertAllowedMethods(self.url, ["GET", "HEAD", "OPTIONS"]) + + def test_put_not_allowed(self): + response = self.client.put(self.url) + self.assertHttpMethodNotAllowed(response) + + def test_delete_not_allowed(self): + response = self.client.delete(self.url) + self.assertHttpMethodNotAllowed(response) + + def test_patch_not_allowed(self): + response = self.client.patch(self.url) + self.assertHttpMethodNotAllowed(response) + + def test_password_reset_form(self): + # Retrieve the password reset form + response = self.client.get(self.url, content_type="application/json") + self.assertHttpOK(response) + + # Verify that the form description matches what we expect + form_desc = json.loads(response.content.decode('utf-8')) + self.assertEqual(form_desc["method"], "post") + self.assertEqual(form_desc["submit_url"], reverse("password_change_request")) + self.assertEqual(form_desc["fields"], [ + { + "name": "email", + "defaultValue": "", + "type": "email", + "required": True, + "label": "Email", + "placeholder": "username@domain.com", + "instructions": u"The email address you used to register with {platform_name}".format( + platform_name=settings.PLATFORM_NAME + ), + "restrictions": { + "min_length": EMAIL_MIN_LENGTH, + "max_length": EMAIL_MAX_LENGTH + }, + "errorMessages": {}, + "supplementalText": "", + "supplementalLink": "", + } + ])