From 898bd8a90e52d5ece9944fdc957dc4a96d878773 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 20 Nov 2019 10:30:01 -0500 Subject: [PATCH] Move RegistrationValidationView into user_authn. --- openedx/core/djangoapps/user_api/urls.py | 6 - .../user_api/validation/__init__.py | 0 .../user_api/validation/tests/__init__.py | 0 .../user_api/validation/tests/test_views.py | 233 ------------------ .../djangoapps/user_api/validation/views.py | 199 --------------- .../core/djangoapps/user_authn/urls_common.py | 21 +- .../djangoapps/user_authn/views/register.py | 193 +++++++++++++++ .../user_authn/views/tests/test_register.py | 229 ++++++++++++++++- 8 files changed, 441 insertions(+), 440 deletions(-) delete mode 100644 openedx/core/djangoapps/user_api/validation/__init__.py delete mode 100644 openedx/core/djangoapps/user_api/validation/tests/__init__.py delete mode 100644 openedx/core/djangoapps/user_api/validation/tests/test_views.py delete mode 100644 openedx/core/djangoapps/user_api/validation/views.py diff --git a/openedx/core/djangoapps/user_api/urls.py b/openedx/core/djangoapps/user_api/urls.py index dcce105ce2..e873b1e2c3 100644 --- a/openedx/core/djangoapps/user_api/urls.py +++ b/openedx/core/djangoapps/user_api/urls.py @@ -19,7 +19,6 @@ from .accounts.views import ( UsernameReplacementView ) from .preferences.views import PreferencesDetailView, PreferencesView -from .validation.views import RegistrationValidationView from .verification_api.views import IDVerificationStatusView ME = AccountViewSet.as_view({ @@ -158,11 +157,6 @@ urlpatterns = [ UsernameReplacementView.as_view(), name='username_replacement' ), - url( - r'^v1/validation/registration$', - RegistrationValidationView.as_view(), - name='registration_validation' - ), url( r'^v1/preferences/{}$'.format(settings.USERNAME_PATTERN), PreferencesView.as_view(), diff --git a/openedx/core/djangoapps/user_api/validation/__init__.py b/openedx/core/djangoapps/user_api/validation/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openedx/core/djangoapps/user_api/validation/tests/__init__.py b/openedx/core/djangoapps/user_api/validation/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openedx/core/djangoapps/user_api/validation/tests/test_views.py b/openedx/core/djangoapps/user_api/validation/tests/test_views.py deleted file mode 100644 index cb758a89bf..0000000000 --- a/openedx/core/djangoapps/user_api/validation/tests/test_views.py +++ /dev/null @@ -1,233 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Tests for an API endpoint for client-side user data validation. -""" - -from __future__ import absolute_import - -import unittest - -import ddt -from django.conf import settings -from django.contrib.auth.models import User -from django.test.utils import override_settings -from django.urls import reverse -from six import text_type -from six.moves import range - -from openedx.core.djangoapps.user_api import accounts -from openedx.core.djangoapps.user_api.accounts.tests import testutils -from openedx.core.djangoapps.user_api.validation.views import RegistrationValidationThrottle -from openedx.core.lib.api import test_utils -from util.password_policy_validators import DEFAULT_MAX_PASSWORD_LENGTH - - -@ddt.ddt -class RegistrationValidationViewTests(test_utils.ApiTestCase): - """ - Tests for validity of user data in registration forms. - """ - - endpoint_name = 'registration_validation' - path = reverse(endpoint_name) - - def get_validation_decision(self, data): - response = self.client.post(self.path, data) - return response.data.get('validation_decisions', {}) - - def assertValidationDecision(self, data, decision): - self.assertEqual( - self.get_validation_decision(data), - decision - ) - - def assertNotValidationDecision(self, data, decision): - self.assertNotEqual( - self.get_validation_decision(data), - decision - ) - - def test_no_decision_for_empty_request(self): - self.assertValidationDecision( - {}, - {} - ) - - def test_no_decision_for_invalid_request(self): - self.assertValidationDecision( - {'invalid_field': 'random_user_data'}, - {} - ) - - @ddt.data( - ['name', [name for name in testutils.VALID_NAMES]], - ['email', [email for email in testutils.VALID_EMAILS]], - ['password', [password for password in testutils.VALID_PASSWORDS]], - ['username', [username for username in testutils.VALID_USERNAMES]], - ['country', [country for country in testutils.VALID_COUNTRIES]] - ) - @ddt.unpack - def test_positive_validation_decision(self, form_field_name, user_data): - """ - Test if {0} as any item in {1} gives a positive validation decision. - """ - self.assertValidationDecision( - {form_field_name: user_data}, - {form_field_name: ''} - ) - - @ddt.data( - # Skip None type for invalidity checks. - ['name', [name for name in testutils.INVALID_NAMES[1:]]], - ['email', [email for email in testutils.INVALID_EMAILS[1:]]], - ['password', [password for password in testutils.INVALID_PASSWORDS[1:]]], - ['username', [username for username in testutils.INVALID_USERNAMES[1:]]], - ['country', [country for country in testutils.INVALID_COUNTRIES[1:]]] - ) - @ddt.unpack - def test_negative_validation_decision(self, form_field_name, user_data): - """ - Test if {0} as any item in {1} gives a negative validation decision. - """ - self.assertNotValidationDecision( - {form_field_name: user_data}, - {form_field_name: ''} - ) - - @ddt.data( - ['username', 'username@email.com'], # No conflict - ['user', 'username@email.com'], # Username conflict - ['username', 'user@email.com'], # Email conflict - ['user', 'user@email.com'] # Both conflict - ) - @ddt.unpack - def test_existence_conflict(self, username, email): - """ - Test if username '{0}' and email '{1}' have conflicts with - username 'user' and email 'user@email.com'. - """ - user = User.objects.create_user(username='user', email='user@email.com') - self.assertValidationDecision( - { - 'username': username, - 'email': email - }, - { - "username": accounts.USERNAME_CONFLICT_MSG.format( - username=user.username - ) if username == user.username else '', - "email": accounts.EMAIL_CONFLICT_MSG.format( - email_address=user.email - ) if email == user.email else '' - } - ) - - @ddt.data('', ('e' * accounts.EMAIL_MAX_LENGTH) + '@email.com') - def test_email_bad_length_validation_decision(self, email): - self.assertValidationDecision( - {'email': email}, - {'email': accounts.EMAIL_BAD_LENGTH_MSG} - ) - - def test_email_generically_invalid_validation_decision(self): - email = 'email' - self.assertValidationDecision( - {'email': email}, - {'email': accounts.EMAIL_INVALID_MSG.format(email=email)} - ) - - def test_confirm_email_matches_email(self): - email = 'user@email.com' - self.assertValidationDecision( - {'email': email, 'confirm_email': email}, - {'email': '', 'confirm_email': ''} - ) - - @ddt.data('', 'users@other.email') - def test_confirm_email_doesnt_equal_email(self, confirm_email): - self.assertValidationDecision( - {'email': 'user@email.com', 'confirm_email': confirm_email}, - {'email': '', 'confirm_email': text_type(accounts.REQUIRED_FIELD_CONFIRM_EMAIL_MSG)} - ) - - @ddt.data( - 'u' * (accounts.USERNAME_MIN_LENGTH - 1), - 'u' * (accounts.USERNAME_MAX_LENGTH + 1) - ) - def test_username_bad_length_validation_decision(self, username): - self.assertValidationDecision( - {'username': username}, - {'username': text_type(accounts.USERNAME_BAD_LENGTH_MSG)} - ) - - @unittest.skipUnless(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames disabled.") - @ddt.data(*testutils.INVALID_USERNAMES_UNICODE) - def test_username_invalid_unicode_validation_decision(self, username): - self.assertValidationDecision( - {'username': username}, - {'username': text_type(accounts.USERNAME_INVALID_CHARS_UNICODE)} - ) - - @unittest.skipIf(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames enabled.") - @ddt.data(*testutils.INVALID_USERNAMES_ASCII) - def test_username_invalid_ascii_validation_decision(self, username): - self.assertValidationDecision( - {'username': username}, - {"username": text_type(accounts.USERNAME_INVALID_CHARS_ASCII)} - ) - - def test_password_empty_validation_decision(self): - # 2 is the default setting for minimum length found in lms/envs/common.py - # under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator - msg = u'This password is too short. It must contain at least 2 characters.' - self.assertValidationDecision( - {'password': ''}, - {"password": msg} - ) - - def test_password_bad_min_length_validation_decision(self): - password = 'p' - # 2 is the default setting for minimum length found in lms/envs/common.py - # under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator - msg = u'This password is too short. It must contain at least 2 characters.' - self.assertValidationDecision( - {'password': password}, - {"password": msg} - ) - - def test_password_bad_max_length_validation_decision(self): - password = 'p' * DEFAULT_MAX_PASSWORD_LENGTH - # 75 is the default setting for maximum length found in lms/envs/common.py - # under AUTH_PASSWORD_VALIDATORS.MaximumLengthValidator - msg = u'This password is too long. It must contain no more than 75 characters.' - self.assertValidationDecision( - {'password': password}, - {"password": msg} - ) - - def test_password_equals_username_validation_decision(self): - self.assertValidationDecision( - {"username": "somephrase", "password": "somephrase"}, - {"username": "", "password": u"The password is too similar to the username."} - ) - - @override_settings( - CACHES={ - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': 'registration_proxy', - } - } - ) - def test_rate_limiting_registration_view(self): - """ - Confirm rate limits work as expected for registration - end point /api/user/v1/validation/registration/. Note - that drf's rate limiting makes use of the default cache - to enforce limits; that's why this test needs a "real" - default cache (as opposed to the usual-for-tests DummyCache) - """ - for _ in range(RegistrationValidationThrottle().num_requests): - self.request_without_auth('post', self.path) - response = self.request_without_auth('post', self.path) - self.assertEqual(response.status_code, 429) diff --git a/openedx/core/djangoapps/user_api/validation/views.py b/openedx/core/djangoapps/user_api/validation/views.py deleted file mode 100644 index ecb0df1781..0000000000 --- a/openedx/core/djangoapps/user_api/validation/views.py +++ /dev/null @@ -1,199 +0,0 @@ -# -*- coding: utf-8 -*- -""" -An API for client-side validation of (potential) user data. -""" - -from __future__ import absolute_import - -from ipware.ip import get_ip -from rest_framework.response import Response -from rest_framework.throttling import AnonRateThrottle -from rest_framework.views import APIView - -from openedx.core.djangoapps.user_api.accounts.api import ( - get_confirm_email_validation_error, - get_country_validation_error, - get_email_existence_validation_error, - get_email_validation_error, - get_name_validation_error, - get_password_validation_error, - get_username_existence_validation_error, - get_username_validation_error -) - - -class RegistrationValidationThrottle(AnonRateThrottle): - """ - Custom throttle rate for /api/user/v1/validation/registration - endpoint's use case. - """ - - scope = 'registration_validation' - - def get_ident(self, request): - client_ip = get_ip(request) - return client_ip - - -class RegistrationValidationView(APIView): - """ - **Use Cases** - - Get validation information about user data during registration. - Client-side may request validation for any number of form fields, - and the API will return a conclusion from its analysis for each - input (i.e. valid or not valid, or a custom, detailed message). - - **Example Requests and Responses** - - - Checks the validity of the username and email inputs separately. - POST /api/user/v1/validation/registration/ - >>> { - >>> "username": "hi_im_new", - >>> "email": "newguy101@edx.org" - >>> } - RESPONSE - >>> { - >>> "validation_decisions": { - >>> "username": "", - >>> "email": "" - >>> } - >>> } - Empty strings indicate that there was no problem with the input. - - - Checks the validity of the password field (its validity depends - upon both the username and password fields, so we need both). If - only password is input, we don't check for password/username - compatibility issues. - POST /api/user/v1/validation/registration/ - >>> { - >>> "username": "myname", - >>> "password": "myname" - >>> } - RESPONSE - >>> { - >>> "validation_decisions": { - >>> "username": "", - >>> "password": "Password cannot be the same as the username." - >>> } - >>> } - - - Checks the validity of the username, email, and password fields - separately, and also tells whether an account exists. The password - field's validity depends upon both the username and password, and - the account's existence depends upon both the username and email. - POST /api/user/v1/validation/registration/ - >>> { - >>> "username": "hi_im_new", - >>> "email": "cto@edx.org", - >>> "password": "p" - >>> } - RESPONSE - >>> { - >>> "validation_decisions": { - >>> "username": "", - >>> "email": "It looks like cto@edx.org belongs to an existing account. Try again with a different email address.", - >>> "password": "Password must be at least 2 characters long", - >>> } - >>> } - In this example, username is valid and (we assume) there is - a preexisting account with that email. The password also seems - to contain the username. - - Note that a validation decision is returned *for all* inputs, whether - positive or negative. - - **Available Handlers** - - "name": - A handler to check the validity of the user's real name. - "username": - A handler to check the validity of usernames. - "email": - A handler to check the validity of emails. - "confirm_email": - A handler to check whether the confirmation email field matches - the email field. - "password": - A handler to check the validity of passwords; a compatibility - decision with the username is made if it exists in the input. - "country": - A handler to check whether the validity of country fields. - """ - - # This end-point is available to anonymous users, so no authentication is needed. - authentication_classes = [] - throttle_classes = (RegistrationValidationThrottle,) - - def name_handler(self, request): - name = request.data.get('name') - return get_name_validation_error(name) - - def username_handler(self, request): - username = request.data.get('username') - invalid_username_error = get_username_validation_error(username) - username_exists_error = get_username_existence_validation_error(username) - # We prefer seeing for invalidity first. - # Some invalid usernames (like for superusers) may exist. - return invalid_username_error or username_exists_error - - def email_handler(self, request): - email = request.data.get('email') - invalid_email_error = get_email_validation_error(email) - email_exists_error = get_email_existence_validation_error(email) - # We prefer seeing for invalidity first. - # Some invalid emails (like a blank one for superusers) may exist. - return invalid_email_error or email_exists_error - - def confirm_email_handler(self, request): - email = request.data.get('email') - confirm_email = request.data.get('confirm_email') - return get_confirm_email_validation_error(confirm_email, email) - - def password_handler(self, request): - username = request.data.get('username') - email = request.data.get('email') - password = request.data.get('password') - return get_password_validation_error(password, username, email) - - def country_handler(self, request): - country = request.data.get('country') - return get_country_validation_error(country) - - validation_handlers = { - "name": name_handler, - "username": username_handler, - "email": email_handler, - "confirm_email": confirm_email_handler, - "password": password_handler, - "country": country_handler - } - - def post(self, request): - """ - POST /api/user/v1/validation/registration/ - - Expects request of the form - >>> { - >>> "name": "Dan the Validator", - >>> "username": "mslm", - >>> "email": "mslm@gmail.com", - >>> "confirm_email": "mslm@gmail.com", - >>> "password": "password123", - >>> "country": "PK" - >>> } - where each key is the appropriate form field name and the value is - user input. One may enter individual inputs if needed. Some inputs - can get extra verification checks if entered along with others, - like when the password may not equal the username. - """ - validation_decisions = {} - for form_field_key in self.validation_handlers: - # For every field requiring validation from the client, - # request a decision for it from the appropriate handler. - if form_field_key in request.data: - handler = self.validation_handlers[form_field_key] - validation_decisions.update({ - form_field_key: handler(self, request) - }) - return Response({"validation_decisions": validation_decisions}) diff --git a/openedx/core/djangoapps/user_authn/urls_common.py b/openedx/core/djangoapps/user_authn/urls_common.py index 45009c884b..13aaf24991 100644 --- a/openedx/core/djangoapps/user_authn/urls_common.py +++ b/openedx/core/djangoapps/user_authn/urls_common.py @@ -5,6 +5,9 @@ Note: The split between urls.py and urls_common.py is hopefully temporary. For now, this is needed because of difference in CMS and LMS that have not yet been cleaned up. +This is also home to urls for endpoints that have been consolidated from other djangoapps, +which leads to inconsistent prefixing. + """ from __future__ import absolute_import @@ -17,12 +20,27 @@ from .views import auto_auth, login, logout, password_reset, register urlpatterns = [ # Registration url(r'^create_account$', register.RegistrationView.as_view(), name='create_account'), + + # Moved from user_api/legacy_urls.py + # `user_api` prefix is preserved for backwards compatibility. url(r'^user_api/v1/account/registration/$', register.RegistrationView.as_view(), name="user_api_registration"), + + # Moved from user_api/urls.py + # `api/user` prefix is preserved for backwards compatibility. + url( + r'^api/user/v1/validation/registration$', + register.RegistrationValidationView.as_view(), + name='registration_validation' + ), + # Login url(r'^login_post$', login.login_user, name='login_post'), url(r'^login_ajax$', login.login_user, name="login"), url(r'^login_ajax/(?P[^/]*)$', login.login_user), + + # Moved from user_api/legacy_urls.py + # `user_api` prefix is preserved for backwards compatibility. url(r'^user_api/v1/account/login_session/$', login.LoginSessionView.as_view(), name="user_api_login_session"), @@ -31,7 +49,8 @@ urlpatterns = [ url(r'^logout$', logout.LogoutView.as_view(), name='logout'), - url(r'^v1/account/password_reset/$', password_reset.PasswordResetView.as_view(), + # Moved from user_api/legacy_urls.py + url(r'^user_api/v1/account/password_reset/$', password_reset.PasswordResetView.as_view(), name="user_api_password_reset"), ] diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index e52318e544..3a5b659995 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -25,6 +25,9 @@ from django.utils.translation import ugettext as _ from pytz import UTC from requests import HTTPError from six import text_type +from ipware.ip import get_ip +from rest_framework.response import Response +from rest_framework.throttling import AnonRateThrottle from rest_framework.views import APIView from social_core.exceptions import AuthAlreadyAssociated, AuthException from social_django import utils as social_utils @@ -36,6 +39,16 @@ from lms.djangoapps.discussion.notification_prefs.views import enable_notificati from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api import accounts as accounts_settings +from openedx.core.djangoapps.user_api.accounts.api import ( + get_confirm_email_validation_error, + get_country_validation_error, + get_email_existence_validation_error, + get_email_validation_error, + get_name_validation_error, + get_password_validation_error, + get_username_existence_validation_error, + get_username_validation_error +) from openedx.core.djangoapps.user_authn.utils import generate_password from openedx.core.djangoapps.user_api.preferences import api as preferences_api from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies @@ -517,3 +530,183 @@ class RegistrationView(APIView): # keeping this `success` field in for now, as we have outstanding clients expecting this response_dict['success'] = True return JsonResponse(response_dict, status=status_code) + + +class RegistrationValidationThrottle(AnonRateThrottle): + """ + Custom throttle rate for /api/user/v1/validation/registration + endpoint's use case. + """ + + scope = 'registration_validation' + + def get_ident(self, request): + client_ip = get_ip(request) + return client_ip + + +# pylint: disable=line-too-long +class RegistrationValidationView(APIView): + """ + **Use Cases** + + Get validation information about user data during registration. + Client-side may request validation for any number of form fields, + and the API will return a conclusion from its analysis for each + input (i.e. valid or not valid, or a custom, detailed message). + + **Example Requests and Responses** + + - Checks the validity of the username and email inputs separately. + POST /api/user/v1/validation/registration/ + >>> { + >>> "username": "hi_im_new", + >>> "email": "newguy101@edx.org" + >>> } + RESPONSE + >>> { + >>> "validation_decisions": { + >>> "username": "", + >>> "email": "" + >>> } + >>> } + Empty strings indicate that there was no problem with the input. + + - Checks the validity of the password field (its validity depends + upon both the username and password fields, so we need both). If + only password is input, we don't check for password/username + compatibility issues. + POST /api/user/v1/validation/registration/ + >>> { + >>> "username": "myname", + >>> "password": "myname" + >>> } + RESPONSE + >>> { + >>> "validation_decisions": { + >>> "username": "", + >>> "password": "Password cannot be the same as the username." + >>> } + >>> } + + - Checks the validity of the username, email, and password fields + separately, and also tells whether an account exists. The password + field's validity depends upon both the username and password, and + the account's existence depends upon both the username and email. + POST /api/user/v1/validation/registration/ + >>> { + >>> "username": "hi_im_new", + >>> "email": "cto@edx.org", + >>> "password": "p" + >>> } + RESPONSE + >>> { + >>> "validation_decisions": { + >>> "username": "", + >>> "email": "It looks like cto@edx.org belongs to an existing account. Try again with a different email address.", + >>> "password": "Password must be at least 2 characters long", + >>> } + >>> } + In this example, username is valid and (we assume) there is + a preexisting account with that email. The password also seems + to contain the username. + + Note that a validation decision is returned *for all* inputs, whether + positive or negative. + + **Available Handlers** + + "name": + A handler to check the validity of the user's real name. + "username": + A handler to check the validity of usernames. + "email": + A handler to check the validity of emails. + "confirm_email": + A handler to check whether the confirmation email field matches + the email field. + "password": + A handler to check the validity of passwords; a compatibility + decision with the username is made if it exists in the input. + "country": + A handler to check whether the validity of country fields. + """ + + # This end-point is available to anonymous users, so no authentication is needed. + authentication_classes = [] + throttle_classes = (RegistrationValidationThrottle,) + + def name_handler(self, request): + name = request.data.get('name') + return get_name_validation_error(name) + + def username_handler(self, request): + """ Validates whether the username is valid. """ + username = request.data.get('username') + invalid_username_error = get_username_validation_error(username) + username_exists_error = get_username_existence_validation_error(username) + # We prefer seeing for invalidity first. + # Some invalid usernames (like for superusers) may exist. + return invalid_username_error or username_exists_error + + def email_handler(self, request): + """ Validates whether the email address is valid. """ + email = request.data.get('email') + invalid_email_error = get_email_validation_error(email) + email_exists_error = get_email_existence_validation_error(email) + # We prefer seeing for invalidity first. + # Some invalid emails (like a blank one for superusers) may exist. + return invalid_email_error or email_exists_error + + def confirm_email_handler(self, request): + email = request.data.get('email') + confirm_email = request.data.get('confirm_email') + return get_confirm_email_validation_error(confirm_email, email) + + def password_handler(self, request): + username = request.data.get('username') + email = request.data.get('email') + password = request.data.get('password') + return get_password_validation_error(password, username, email) + + def country_handler(self, request): + country = request.data.get('country') + return get_country_validation_error(country) + + validation_handlers = { + "name": name_handler, + "username": username_handler, + "email": email_handler, + "confirm_email": confirm_email_handler, + "password": password_handler, + "country": country_handler + } + + def post(self, request): + """ + POST /api/user/v1/validation/registration/ + + Expects request of the form + >>> { + >>> "name": "Dan the Validator", + >>> "username": "mslm", + >>> "email": "mslm@gmail.com", + >>> "confirm_email": "mslm@gmail.com", + >>> "password": "password123", + >>> "country": "PK" + >>> } + where each key is the appropriate form field name and the value is + user input. One may enter individual inputs if needed. Some inputs + can get extra verification checks if entered along with others, + like when the password may not equal the username. + """ + validation_decisions = {} + for form_field_key in self.validation_handlers: + # For every field requiring validation from the client, + # request a decision for it from the appropriate handler. + if form_field_key in request.data: + handler = self.validation_handlers[form_field_key] + validation_decisions.update({ + form_field_key: handler(self, request) + }) + return Response({"validation_decisions": validation_decisions}) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index 7b6d7e68e4..04f6f3976e 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -3,13 +3,15 @@ from __future__ import absolute_import import json -from unittest import skipUnless +from unittest import skipIf, skipUnless from datetime import datetime import ddt import httpretty import mock import six +from six.moves import range + from django.conf import settings from django.contrib.auth.models import User from django.core import mail @@ -24,14 +26,22 @@ from social_django.models import Partial, UserSocialAuth from openedx.core.djangoapps.site_configuration.helpers import get_value from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration from openedx.core.djangoapps.user_api.accounts import ( + EMAIL_BAD_LENGTH_MSG, + EMAIL_CONFLICT_MSG, + EMAIL_INVALID_MSG, EMAIL_MAX_LENGTH, EMAIL_MIN_LENGTH, NAME_MAX_LENGTH, + REQUIRED_FIELD_CONFIRM_EMAIL_MSG, USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_BAD_LENGTH_MSG, + USERNAME_CONFLICT_MSG, + USERNAME_INVALID_CHARS_ASCII, + USERNAME_INVALID_CHARS_UNICODE, ) from openedx.core.djangoapps.user_api.accounts.api import get_account_settings +from openedx.core.djangoapps.user_api.accounts.tests import testutils from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import RetirementTestCase, fake_requested_retirement, @@ -40,7 +50,9 @@ from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( from openedx.core.djangoapps.user_api.tests.test_helpers import TestCaseForm from openedx.core.djangoapps.user_api.tests.test_constants import SORTED_COUNTRIES from openedx.core.djangoapps.user_api.tests.test_views import UserAPITestCase +from openedx.core.djangoapps.user_authn.views.register import RegistrationValidationThrottle from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms +from openedx.core.lib.api import test_utils from student.tests.factories import UserFactory from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin, simulate_running_pipeline from third_party_auth.tests.utils import ( @@ -49,6 +61,7 @@ from third_party_auth.tests.utils import ( ThirdPartyOAuthTestMixinGoogle ) from util.password_policy_validators import ( + DEFAULT_MAX_PASSWORD_LENGTH, create_validator_config, password_validators_instruction_texts, password_validators_restrictions @@ -1845,3 +1858,217 @@ class TestGoogleRegistrationView( ): """Tests the User API registration endpoint with Google authentication.""" __test__ = True + + +@ddt.ddt +class RegistrationValidationViewTests(test_utils.ApiTestCase): + """ + Tests for validity of user data in registration forms. + """ + + endpoint_name = 'registration_validation' + path = reverse(endpoint_name) + + def get_validation_decision(self, data): + response = self.client.post(self.path, data) + return response.data.get('validation_decisions', {}) + + def assertValidationDecision(self, data, decision): + self.assertEqual( + self.get_validation_decision(data), + decision + ) + + def assertNotValidationDecision(self, data, decision): + self.assertNotEqual( + self.get_validation_decision(data), + decision + ) + + def test_no_decision_for_empty_request(self): + self.assertValidationDecision( + {}, + {} + ) + + def test_no_decision_for_invalid_request(self): + self.assertValidationDecision( + {'invalid_field': 'random_user_data'}, + {} + ) + + @ddt.data( + ['name', [name for name in testutils.VALID_NAMES]], + ['email', [email for email in testutils.VALID_EMAILS]], + ['password', [password for password in testutils.VALID_PASSWORDS]], + ['username', [username for username in testutils.VALID_USERNAMES]], + ['country', [country for country in testutils.VALID_COUNTRIES]] + ) + @ddt.unpack + def test_positive_validation_decision(self, form_field_name, user_data): + """ + Test if {0} as any item in {1} gives a positive validation decision. + """ + self.assertValidationDecision( + {form_field_name: user_data}, + {form_field_name: ''} + ) + + @ddt.data( + # Skip None type for invalidity checks. + ['name', [name for name in testutils.INVALID_NAMES[1:]]], + ['email', [email for email in testutils.INVALID_EMAILS[1:]]], + ['password', [password for password in testutils.INVALID_PASSWORDS[1:]]], + ['username', [username for username in testutils.INVALID_USERNAMES[1:]]], + ['country', [country for country in testutils.INVALID_COUNTRIES[1:]]] + ) + @ddt.unpack + def test_negative_validation_decision(self, form_field_name, user_data): + """ + Test if {0} as any item in {1} gives a negative validation decision. + """ + self.assertNotValidationDecision( + {form_field_name: user_data}, + {form_field_name: ''} + ) + + @ddt.data( + ['username', 'username@email.com'], # No conflict + ['user', 'username@email.com'], # Username conflict + ['username', 'user@email.com'], # Email conflict + ['user', 'user@email.com'] # Both conflict + ) + @ddt.unpack + def test_existence_conflict(self, username, email): + """ + Test if username '{0}' and email '{1}' have conflicts with + username 'user' and email 'user@email.com'. + """ + user = User.objects.create_user(username='user', email='user@email.com') + self.assertValidationDecision( + { + 'username': username, + 'email': email + }, + { + # pylint: disable=no-member + "username": USERNAME_CONFLICT_MSG.format( + username=user.username + ) if username == user.username else '', + # pylint: disable=no-member + "email": EMAIL_CONFLICT_MSG.format( + email_address=user.email + ) if email == user.email else '' + } + ) + + @ddt.data('', ('e' * EMAIL_MAX_LENGTH) + '@email.com') + def test_email_bad_length_validation_decision(self, email): + self.assertValidationDecision( + {'email': email}, + {'email': EMAIL_BAD_LENGTH_MSG} + ) + + def test_email_generically_invalid_validation_decision(self): + email = 'email' + self.assertValidationDecision( + {'email': email}, + # pylint: disable=no-member + {'email': EMAIL_INVALID_MSG.format(email=email)} + ) + + def test_confirm_email_matches_email(self): + email = 'user@email.com' + self.assertValidationDecision( + {'email': email, 'confirm_email': email}, + {'email': '', 'confirm_email': ''} + ) + + @ddt.data('', 'users@other.email') + def test_confirm_email_doesnt_equal_email(self, confirm_email): + self.assertValidationDecision( + {'email': 'user@email.com', 'confirm_email': confirm_email}, + {'email': '', 'confirm_email': six.text_type(REQUIRED_FIELD_CONFIRM_EMAIL_MSG)} + ) + + @ddt.data( + 'u' * (USERNAME_MIN_LENGTH - 1), + 'u' * (USERNAME_MAX_LENGTH + 1) + ) + def test_username_bad_length_validation_decision(self, username): + self.assertValidationDecision( + {'username': username}, + {'username': six.text_type(USERNAME_BAD_LENGTH_MSG)} + ) + + @skipUnless(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames disabled.") + @ddt.data(*testutils.INVALID_USERNAMES_UNICODE) + def test_username_invalid_unicode_validation_decision(self, username): + self.assertValidationDecision( + {'username': username}, + {'username': six.text_type(USERNAME_INVALID_CHARS_UNICODE)} + ) + + @skipIf(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames enabled.") + @ddt.data(*testutils.INVALID_USERNAMES_ASCII) + def test_username_invalid_ascii_validation_decision(self, username): + self.assertValidationDecision( + {'username': username}, + {"username": six.text_type(USERNAME_INVALID_CHARS_ASCII)} + ) + + def test_password_empty_validation_decision(self): + # 2 is the default setting for minimum length found in lms/envs/common.py + # under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator + msg = u'This password is too short. It must contain at least 2 characters.' + self.assertValidationDecision( + {'password': ''}, + {"password": msg} + ) + + def test_password_bad_min_length_validation_decision(self): + password = 'p' + # 2 is the default setting for minimum length found in lms/envs/common.py + # under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator + msg = u'This password is too short. It must contain at least 2 characters.' + self.assertValidationDecision( + {'password': password}, + {"password": msg} + ) + + def test_password_bad_max_length_validation_decision(self): + password = 'p' * DEFAULT_MAX_PASSWORD_LENGTH + # 75 is the default setting for maximum length found in lms/envs/common.py + # under AUTH_PASSWORD_VALIDATORS.MaximumLengthValidator + msg = u'This password is too long. It must contain no more than 75 characters.' + self.assertValidationDecision( + {'password': password}, + {"password": msg} + ) + + def test_password_equals_username_validation_decision(self): + self.assertValidationDecision( + {"username": "somephrase", "password": "somephrase"}, + {"username": "", "password": u"The password is too similar to the username."} + ) + + @override_settings( + CACHES={ + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'registration_proxy', + } + } + ) + def test_rate_limiting_registration_view(self): + """ + Confirm rate limits work as expected for registration + end point /api/user/v1/validation/registration/. Note + that drf's rate limiting makes use of the default cache + to enforce limits; that's why this test needs a "real" + default cache (as opposed to the usual-for-tests DummyCache) + """ + for _ in range(RegistrationValidationThrottle().num_requests): + self.request_without_auth('post', self.path) + response = self.request_without_auth('post', self.path) + self.assertEqual(response.status_code, 429)