diff --git a/RELEASE.rst b/RELEASE.rst index b22aadc6e..5ec2c1c76 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,18 @@ Release Notes ============= +Version 0.5.1 +------------- + +- Fixed encrypted response getting ascii-escaped +- add feature site nofication through cms (#309) +- Added hubspot ecommerce bridge (#276) +- Move Header Bundle back to Original Location +- Use query parameters when loading checkout page (#283) +- Fix coupon apply button bug (#296) +- Added SDN compliance api and data model +- Convert Sections to Generic + Version 0.5.0 (Released May 22, 2019) ------------- diff --git a/app.json b/app.json index 024116407..749c866f5 100644 --- a/app.json +++ b/app.json @@ -35,9 +35,17 @@ "CYBERSOURCE_ACCESS_KEY": { "description": "CyberSource Access Key" }, + "CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY": { + "description": "The public key to encrypt export results with for our own security purposes. Should be a base64 encoded NaCl public key.", + "required": false + }, "CYBERSOURCE_PROFILE_ID": { "description": "CyberSource Profile ID" }, + "CYBERSOURCE_MERCHANT_ID": { + "description": "The cybersource merchant id", + "required": false + }, "CYBERSOURCE_REFERENCE_PREFIX": { "description": "a string prefix to identify the application in CyberSource transactions" }, @@ -47,6 +55,14 @@ "CYBERSOURCE_SECURITY_KEY": { "description": "CyberSource API key" }, + "CYBERSOURCE_TRANSACTION_KEY": { + "description": "The cybersource transaction key", + "required": false + }, + "CYBERSOURCE_WSDL_URL": { + "description": "The URL to the cybersource WSDL", + "required": false + }, "GA_TRACKING_ID": { "description": "Google analytics tracking ID", "required": false diff --git a/authentication/exceptions.py b/authentication/exceptions.py index d00854d7f..1dc392e7f 100644 --- a/authentication/exceptions.py +++ b/authentication/exceptions.py @@ -76,5 +76,13 @@ class UnexpectedExistingUserException(PartialException): """Raised if a user already exists but shouldn't in the given pipeline step""" +class UserExportBlockedException(AuthException): + """The user is blocked for export reasons from continuing to sign up""" + + +class UserTryAgainLaterException(AuthException): + """The user should try to register again later""" + + class UserMissingSocialAuthException(Exception): """Raised if the user doesn't have a social auth""" diff --git a/authentication/pipeline/compliance.py b/authentication/pipeline/compliance.py new file mode 100644 index 000000000..7d64990d0 --- /dev/null +++ b/authentication/pipeline/compliance.py @@ -0,0 +1,70 @@ +"""Compliance pipeline actions""" +import logging + +from django.conf import settings +from django.core import mail +from social_core.exceptions import AuthException + +from authentication.exceptions import ( + UserExportBlockedException, + UserTryAgainLaterException, +) +from compliance import api + + +log = logging.getLogger() + + +def verify_exports_compliance( + strategy, backend, user=None, **kwargs +): # pylint: disable=unused-argument + """ + Verify that the user is allowed by exports compliance + + Args: + strategy (social_django.strategy.DjangoStrategy): the strategy used to authenticate + backend (social_core.backends.base.BaseAuth): the backend being used to authenticate + user (User): the current user + """ + if not api.is_exports_verification_enabled(): + log.warning("Export compliance checks are disabled") + return {} + + # skip this step if the user is active or they have an existing export inquiry logged + if user.is_active and user.exports_inquiries.exists(): + return {} + + try: + export_inquiry = api.verify_user_with_exports(user) + except Exception as exc: # pylint: disable=broad-except + # hard failure to request the exports API, log an error but don't let the user proceed + log.exception("Unable to verify exports compliance") + raise UserTryAgainLaterException(backend) from exc + + if export_inquiry is None: + raise UserTryAgainLaterException(backend) + elif export_inquiry.is_denied: + log.info( + "User with email '%s' was denied due to exports violation, for reason_code=%s, info_code=%s", + user.email, + export_inquiry.reason_code, + export_inquiry.info_code, + ) + try: + with mail.get_connection(settings.NOTIFICATION_EMAIL_BACKEND) as connection: + mail.send_mail( + f"Exports Compliance: denied {user.email}", + f"User with email '{user.email}' was denied due to exports violation, for reason_code={export_inquiry.reason_code}, info_code={export_inquiry.info_code}", + settings.MAILGUN_FROM_EMAIL, + [settings.EMAIL_SUPPORT], + connection=connection, + ) + except Exception: # pylint: disable=broad-except + log.exception( + "Exception sending email to support regarding export compliance check failure" + ) + raise UserExportBlockedException(backend) + elif export_inquiry.is_unknown: + raise AuthException("Unable to authenticate, please contact support") + + return {} diff --git a/authentication/pipeline/compliance_test.py b/authentication/pipeline/compliance_test.py new file mode 100644 index 000000000..d6b2d3809 --- /dev/null +++ b/authentication/pipeline/compliance_test.py @@ -0,0 +1,110 @@ +"""Compliance pipeline tests""" +import pytest +from social_core.exceptions import AuthException + +from authentication.exceptions import ( + UserExportBlockedException, + UserTryAgainLaterException, +) +from authentication.pipeline import compliance +from compliance.factories import ExportsInquiryLogFactory + + +pytestmark = pytest.mark.django_db + + +def test_verify_exports_compliance_disabled(mocker): + """Assert that nothing is done when the api is disabled""" + mock_api = mocker.patch("authentication.pipeline.compliance.api") + mock_api.is_exports_verification_enabled.return_value = False + + assert compliance.verify_exports_compliance(None, None) == {} + + +@pytest.mark.parametrize( + "is_active, inquiry_exists, should_verify", + [ + [True, True, False], + [True, False, True], + [False, True, True], + [False, False, True], + ], +) +def test_verify_exports_compliance_user_active( + mailoutbox, mocker, user, is_active, inquiry_exists, should_verify +): # pylint: disable=too-many-arguments + """Assert that the user is verified only if they already haven't been""" + user.is_active = is_active + if inquiry_exists: + ExportsInquiryLogFactory.create(user=user) + + mock_api = mocker.patch("authentication.pipeline.compliance.api") + mock_api.verify_user_with_exports.return_value = mocker.Mock( + is_denied=False, is_unknown=False + ) + + assert compliance.verify_exports_compliance(None, None, user=user) == {} + + if should_verify: + mock_api.verify_user_with_exports.assert_called_once_with(user) + assert len(mailoutbox) == 0 + + +def test_verify_exports_compliance_no_record(mocker, user): + """Assert that an error to try again later is raised if no ExportsInquiryLog is created""" + + mock_api = mocker.patch("authentication.pipeline.compliance.api") + mock_api.verify_user_with_exports.return_value = None + + with pytest.raises(UserTryAgainLaterException): + compliance.verify_exports_compliance(None, None, user=user) + + mock_api.verify_user_with_exports.assert_called_once_with(user) + + +def test_verify_exports_compliance_api_raises_exception(mocker, user): + """Assert that an error to try again later is raised if the export api raises an exception""" + + mock_api = mocker.patch("authentication.pipeline.compliance.api") + mock_api.verify_user_with_exports.side_effect = Exception("error") + + with pytest.raises(UserTryAgainLaterException): + compliance.verify_exports_compliance(None, None, user=user) + + mock_api.verify_user_with_exports.assert_called_once_with(user) + + +@pytest.mark.parametrize("email_fails", [True, False]) +def test_verify_exports_compliance_denied(mailoutbox, mocker, user, email_fails): + """Assert that a UserExportBlockedException is raised if the inquiry result is denied""" + mock_api = mocker.patch("authentication.pipeline.compliance.api") + mock_api.verify_user_with_exports.return_value = mocker.Mock( + is_denied=True, is_unknown=False, reason_code=100, info_code="123" + ) + + if email_fails: + # a mail sending error should not obscurve the true error + mocker.patch( + "authentication.pipeline.compliance.mail.send_mail", + side_effect=Exception("mail error"), + ) + + with pytest.raises(UserExportBlockedException): + compliance.verify_exports_compliance(None, None, user=user) + + mock_api.verify_user_with_exports.assert_called_once_with(user) + assert len(mailoutbox) == (0 if email_fails else 1) + + +def test_verify_exports_compliance_unknown(mailoutbox, mocker, user): + """Assert that a UserExportBlockedException is raised if the inquiry result is unknown""" + mock_api = mocker.patch("authentication.pipeline.compliance.api") + mock_api.verify_user_with_exports.return_value = mocker.Mock( + is_denied=False, is_unknown=True + ) + + with pytest.raises(AuthException): + compliance.verify_exports_compliance(None, None, user=user) + + mock_api.verify_user_with_exports.assert_called_once_with(user) + assert len(mailoutbox) == 0 diff --git a/authentication/pipeline/user.py b/authentication/pipeline/user.py index 8d85efa3b..e7f91b723 100644 --- a/authentication/pipeline/user.py +++ b/authentication/pipeline/user.py @@ -14,6 +14,8 @@ UnexpectedExistingUserException, ) from authentication.utils import SocialAuthState + +from compliance import api as compliance_api from users.serializers import UserSerializer, ProfileSerializer # pylint: disable=keyword-arg-before-vararg @@ -176,10 +178,34 @@ def forbid_hijack(strategy, backend, **kwargs): # pylint: disable=unused-argume Args: strategy (social_django.strategy.DjangoStrategy): the strategy used to authenticate backend (social_core.backends.base.BaseAuth): the backend being used to authenticate - user (User): the current user - flow (str): the type of flow (login or register) """ # As first step in pipeline, stop a hijacking admin from going any further if strategy.session_get("is_hijacked_user"): raise AuthException("You are hijacking another user, don't try to login again") return {} + + +def activate_user( + strategy, backend, user=None, is_new=False, **kwargs +): # pylint: disable=unused-argument + """ + Activate the user's account if they passed export controls + + Args: + strategy (social_django.strategy.DjangoStrategy): the strategy used to authenticate + backend (social_core.backends.base.BaseAuth): the backend being used to authenticate + user (User): the current user + """ + if user.is_active or not is_new: + return {} + + export_inquiry = user.exports_inquiries.order_by("-created_on").first() + + # if the user has an export inquiry that is considered successful, activate them + if not compliance_api.is_exports_verification_enabled() or ( + export_inquiry is not None and export_inquiry.is_success + ): + user.is_active = True + user.save() + + return {} diff --git a/authentication/pipeline/user_test.py b/authentication/pipeline/user_test.py index 3a1908041..bda4cec20 100644 --- a/authentication/pipeline/user_test.py +++ b/authentication/pipeline/user_test.py @@ -18,6 +18,8 @@ RequireUserException, ) from authentication.utils import SocialAuthState +from compliance.constants import RESULT_SUCCESS, RESULT_DENIED, RESULT_UNKNOWN +from compliance.factories import ExportsInquiryLogFactory @pytest.fixture @@ -371,3 +373,35 @@ def test_forbid_hijack(mocker, hijacked): user_actions.forbid_hijack(*args, **kwargs) else: assert user_actions.forbid_hijack(*args, **kwargs) == {} + + +@pytest.mark.parametrize("is_active", [True, False]) +@pytest.mark.parametrize("is_new", [True, False]) +@pytest.mark.parametrize( + "is_enabled, has_inquiry, computed_result, expected", + [ + [True, True, RESULT_SUCCESS, True], # feature enabled, result is success + [True, True, RESULT_DENIED, False], # feature enabled, result is denied + [True, True, RESULT_UNKNOWN, False], # feature enabled, result is unknown + [False, False, None, True], # feature disabled + [True, False, None, False], # feature enabled, no result + ], +) +def test_activate_user( + mocker, user, is_active, is_new, is_enabled, has_inquiry, computed_result, expected +): # pylint: disable=too-many-arguments + """Test that activate_user takes the correct action""" + user.is_active = is_active + if has_inquiry: + ExportsInquiryLogFactory.create(user=user, computed_result=computed_result) + + mocker.patch( + "authentication.pipeline.user.compliance_api.is_exports_verification_enabled", + return_value=is_enabled, + ) + + assert user_actions.activate_user(None, None, user=user, is_new=is_new) == {} + + if not user.is_active and is_new: + # only if the user is inactive and just registered + assert user.is_active is expected diff --git a/authentication/serializers.py b/authentication/serializers.py index c595a58d6..ef7f564b2 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -21,6 +21,8 @@ RequireProviderException, RequireRegistrationException, RequireProfileException, + UserExportBlockedException, + UserTryAgainLaterException, ) from authentication.utils import SocialAuthState @@ -284,6 +286,17 @@ def create(self, validated_data): result = SocialAuthState( SocialAuthState.STATE_REGISTER_EXTRA_DETAILS, partial=exc.partial ) + except UserExportBlockedException: + result = SocialAuthState( + SocialAuthState.STATE_USER_BLOCKED, + errors=["Unable to complete registration, please contact support"], + ) + except UserTryAgainLaterException: + result = SocialAuthState( + SocialAuthState.STATE_ERROR_TEMPORARY, + errors=["Unable to register at this time, please try again later"], + ) + return result diff --git a/authentication/utils.py b/authentication/utils.py index 4ebdd6120..1875dc173 100644 --- a/authentication/utils.py +++ b/authentication/utils.py @@ -24,8 +24,10 @@ class SocialAuthState: # end states STATE_SUCCESS = "success" STATE_ERROR = "error" + STATE_ERROR_TEMPORARY = "error-temporary" STATE_INACTIVE = "inactive" STATE_INVALID_EMAIL = "invalid-email" + STATE_USER_BLOCKED = "user-blocked" def __init__( self, diff --git a/authentication/views_test.py b/authentication/views_test.py index 3a3f98473..08c9a7402 100644 --- a/authentication/views_test.py +++ b/authentication/views_test.py @@ -1,16 +1,41 @@ """Tests for authentication views""" # pylint: disable=redefined-outer-name +from contextlib import contextmanager, ExitStack +from unittest.mock import patch +from django.conf import settings from django.contrib.auth import get_user, get_user_model +from django.core import mail +from django.db import transaction from django.urls import reverse +from django.test import Client, override_settings import factory +from faker import Faker +from hypothesis import settings as hypothesis_settings, strategies as st, Verbosity +from hypothesis.stateful import ( + consumes, + precondition, + rule, + Bundle, + RuleBasedStateMachine, + HealthCheck, +) +from hypothesis.extra.django import TestCase as HTestCase import pytest +import responses from rest_framework import status from social_core.backends.email import EmailAuth from authentication.serializers import PARTIAL_PIPELINE_TOKEN_KEY from authentication.utils import SocialAuthState -from users.factories import UserSocialAuthFactory +from compliance.constants import RESULT_DENIED, RESULT_SUCCESS +from compliance.models import ExportsInquiryLog +from compliance.test_utils import ( + get_cybersource_test_settings, + mock_cybersource_wsdl, + mock_cybersource_wsdl_operation, +) +from users.factories import UserFactory, UserSocialAuthFactory from mitxpro.test_utils import any_instance_of, MockResponse pytestmark = [pytest.mark.django_db] @@ -20,6 +45,8 @@ User = get_user_model() +fake = Faker() + @pytest.fixture def email_user(user): @@ -49,7 +76,7 @@ def assert_api_call( "redirect_url": None, "extra_data": {}, "state": None, - "provider": None, + "provider": EmailAuth.name, "flow": None, "partial_token": any_instance_of(str), } @@ -68,233 +95,238 @@ def mock_email_send(mocker): yield mocker.patch("mail.verification_api.send_verification_email") -@pytest.fixture() -def mock_recaptcha_success(mocker): - """ Mock Google recaptcha request""" - yield mocker.patch( - "authentication.views.requests.post", - return_value=MockResponse( - content='{"success": true}', status_code=status.HTTP_200_OK - ), - ) +@contextmanager +def export_check_response(response_name): + """Context manager for configuring export check responses""" + with override_settings( + **get_cybersource_test_settings() + ), responses.RequestsMock() as mocked_responses: + mock_cybersource_wsdl(mocked_responses, settings) + mock_cybersource_wsdl_operation(mocked_responses, response_name) + yield -@pytest.fixture() -def mock_recaptcha_failure(mocker): - """ Mock Google recaptcha request""" - yield mocker.patch( +class AuthStateMachine(RuleBasedStateMachine): + """ + State machine for auth flows + + How to understand this code: + + This code exercises our social auth APIs, which is basically a graph of nodes and edges that the user traverses. + You can understand the bundles defined below to be the nodes and the methods of this class to be the edges. + + If you add a new state to the auth flows, create a new bundle to represent that state and define + methods to define transitions into and (optionally) out of that state. + """ + + ConfirmationSentAuthStates = Bundle("confirmation-sent") + ConfirmationRedeemedAuthStates = Bundle("confirmation-redeemed") + RegisterExtraDetailsAuthStates = Bundle("register-details-extra") + + LoginPasswordAuthStates = Bundle("login-password") + + recaptcha_patcher = patch( "authentication.views.requests.post", return_value=MockResponse( - content='{"success": false, "error-codes": ["bad-request"]}', - status_code=status.HTTP_200_OK, + content='{"success": true}', status_code=status.HTTP_200_OK ), ) + email_send_patcher = patch( + "mail.verification_api.send_verification_email", autospec=True + ) + def __init__(self): + """Setup the machine""" + super().__init__() + # wrap the execution in a django transaction, similar to django's TestCase + self.atomic = transaction.atomic() + self.atomic.__enter__() -@pytest.fixture() -def login_email_exists(client, email_user): - """Yield a function for this step""" + # wrap the execution in a patch() + self.mock_email_send = self.email_send_patcher.start() - def run_step(last_result): # pylint: disable=unused-argument - """Run the step""" - return assert_api_call( - client, - "psa-login-email", - { - "flow": SocialAuthState.FLOW_LOGIN, - "email": email_user.email, - "next": NEXT_URL, - }, - { - "flow": SocialAuthState.FLOW_LOGIN, - "provider": EmailAuth.name, - "state": SocialAuthState.STATE_LOGIN_PASSWORD, - "extra_data": {"name": email_user.name}, - }, - ) + # django test client + self.client = Client() - yield run_step + # shared data + self.email = fake.email() + self.user = None + # track whether we've hit an action that starts a flow or not + self.flow_started = False -@pytest.fixture() -def login_email_next(client, email_user): - """Yield a function for this step""" + def teardown(self): + """Cleanup from a run""" + # clear the mailbox + del mail.outbox[:] - def run_step(last_result): # pylint: disable=unused-argument - """Run the step""" - return assert_api_call( - client, - "psa-login-email", - { - "flow": SocialAuthState.FLOW_LOGIN, - "email": email_user.email, - "next": NEXT_URL, - }, - { - "flow": SocialAuthState.FLOW_LOGIN, - "provider": EmailAuth.name, - "state": SocialAuthState.STATE_LOGIN_PASSWORD, - "extra_data": {"name": email_user.name}, - }, - ) - - yield run_step + # stop the patch + self.email_send_patcher.stop() + # end the transaction with a rollback to cleanup any state + transaction.set_rollback(True) + self.atomic.__exit__(None, None, None) -@pytest.fixture() -def register_email_exists(client, user, mock_email_send): - """Yield a function for this step""" - - def run_step(last_result): # pylint: disable=unused-argument - """Run the step""" - result = assert_api_call( - client, - "psa-register-email", - { - "flow": SocialAuthState.FLOW_REGISTER, - "email": user.email, - "next": NEXT_URL, - }, - { - "errors": ["Password is required to login"], - "flow": SocialAuthState.FLOW_REGISTER, - "provider": EmailAuth.name, - "state": SocialAuthState.STATE_LOGIN_PASSWORD, - }, + def create_existing_user(self): + """Create an existing user""" + self.user = UserFactory.create(email=self.email) + UserSocialAuthFactory.create( + user=self.user, provider=EmailAuth.name, uid=self.user.email ) - mock_email_send.assert_not_called() - return result - - yield run_step - -@pytest.fixture() -def login_email_not_exists(client): - """Yield a function for this step""" - - def run_step(last_result): # pylint: disable=unused-argument - """Run the step""" - result = assert_api_call( - client, + @rule( + target=ConfirmationSentAuthStates, + recaptcha_enabled=st.sampled_from([True, False]), + ) + @precondition(lambda self: not self.flow_started) + def register_email_not_exists(self, recaptcha_enabled): + """Register email not exists""" + self.flow_started = True + + with ExitStack() as stack: + mock_recaptcha_success = None + if recaptcha_enabled: + mock_recaptcha_success = stack.enter_context(self.recaptcha_patcher) + stack.enter_context(override_settings(**{"RECAPTCHA_SITE_KEY": "fake"})) + result = assert_api_call( + self.client, + "psa-register-email", + { + "flow": SocialAuthState.FLOW_REGISTER, + "email": self.email, + **({"recaptcha": "fake"} if recaptcha_enabled else {}), + }, + { + "flow": SocialAuthState.FLOW_REGISTER, + "partial_token": None, + "state": SocialAuthState.STATE_REGISTER_CONFIRM_SENT, + }, + ) + self.mock_email_send.assert_called_once() + if mock_recaptcha_success: + mock_recaptcha_success.assert_called_once() + return result + + @rule( + target=LoginPasswordAuthStates, recaptcha_enabled=st.sampled_from([True, False]) + ) + @precondition(lambda self: not self.flow_started) + def register_email_exists(self, recaptcha_enabled): + """Register email exists""" + self.flow_started = True + self.create_existing_user() + + with ExitStack() as stack: + mock_recaptcha_success = None + if recaptcha_enabled: + mock_recaptcha_success = stack.enter_context(self.recaptcha_patcher) + stack.enter_context(override_settings(**{"RECAPTCHA_SITE_KEY": "fake"})) + + result = assert_api_call( + self.client, + "psa-register-email", + { + "flow": SocialAuthState.FLOW_REGISTER, + "email": self.email, + "next": NEXT_URL, + **({"recaptcha": "fake"} if recaptcha_enabled else {}), + }, + { + "flow": SocialAuthState.FLOW_REGISTER, + "state": SocialAuthState.STATE_LOGIN_PASSWORD, + "errors": ["Password is required to login"], + }, + ) + self.mock_email_send.assert_not_called() + if mock_recaptcha_success: + mock_recaptcha_success.assert_called_once() + return result + + @rule() + @precondition(lambda self: not self.flow_started) + def register_email_not_exists_with_recaptcha_invalid(self): + """Yield a function for this step""" + self.flow_started = True + with patch( + "authentication.views.requests.post", + return_value=MockResponse( + content='{"success": false, "error-codes": ["bad-request"]}', + status_code=status.HTTP_200_OK, + ), + ) as mock_recaptcha_failure, override_settings( + **{"RECAPTCHA_SITE_KEY": "fakse"} + ): + assert_api_call( + self.client, + "psa-register-email", + { + "flow": SocialAuthState.FLOW_REGISTER, + "email": NEW_EMAIL, + "recaptcha": "fake", + }, + {"error-codes": ["bad-request"], "success": False}, + expect_status=status.HTTP_400_BAD_REQUEST, + use_defaults=False, + ) + mock_recaptcha_failure.assert_called_once() + self.mock_email_send.assert_not_called() + + @rule() + @precondition(lambda self: not self.flow_started) + def login_email_not_exists(self): + """Login for an email that doesn't exist""" + self.flow_started = True + assert_api_call( + self.client, "psa-login-email", - {"flow": SocialAuthState.FLOW_LOGIN, "email": NEW_EMAIL}, + {"flow": SocialAuthState.FLOW_LOGIN, "email": self.email}, { "errors": ["Couldn't find your account"], "flow": SocialAuthState.FLOW_LOGIN, - "provider": EmailAuth.name, "partial_token": None, "state": SocialAuthState.STATE_ERROR, }, ) - assert User.objects.filter(email=NEW_EMAIL).exists() is False - return result - - yield run_step + assert User.objects.filter(email=self.email).exists() is False + @rule(target=LoginPasswordAuthStates) + @precondition(lambda self: not self.flow_started) + def login_email_exists(self): + """Login with a user that exists""" + self.flow_started = True + self.create_existing_user() -@pytest.fixture() -def register_email_not_exists(client, mock_email_send): - """Yield a function for this step""" - - def run_step(last_result): # pylint: disable=unused-argument - """Run the step""" - result = assert_api_call( - client, - "psa-register-email", - {"flow": SocialAuthState.FLOW_REGISTER, "email": NEW_EMAIL}, - { - "flow": SocialAuthState.FLOW_REGISTER, - "provider": EmailAuth.name, - "partial_token": None, - "state": SocialAuthState.STATE_REGISTER_CONFIRM_SENT, - }, - ) - mock_email_send.assert_called_once() - assert User.objects.filter(email=NEW_EMAIL).exists() is False - return result - - yield run_step - - -@pytest.fixture() -def register_email_not_exists_with_recaptcha( - settings, client, mock_email_send, mock_recaptcha_success -): - """Yield a function for this step""" - - def run_step(last_result): # pylint: disable=unused-argument - """Run the step""" - settings.RECAPTCHA_SITE_KEY = "fake" - result = assert_api_call( - client, - "psa-register-email", - { - "flow": SocialAuthState.FLOW_REGISTER, - "email": NEW_EMAIL, - "recaptcha": "fake", - }, + return assert_api_call( + self.client, + "psa-login-email", { - "flow": SocialAuthState.FLOW_REGISTER, - "provider": EmailAuth.name, - "partial_token": None, - "state": SocialAuthState.STATE_REGISTER_CONFIRM_SENT, + "flow": SocialAuthState.FLOW_LOGIN, + "email": self.user.email, + "next": NEXT_URL, }, - ) - mock_recaptcha_success.assert_called_once() - mock_email_send.assert_called_once() - return result - - yield run_step - - -@pytest.fixture() -def register_email_not_exists_with_recaptcha_invalid( - settings, client, mock_email_send, mock_recaptcha_failure -): - """Yield a function for this step""" - - def run_step(last_result): # pylint: disable=unused-argument - """Run the step""" - settings.RECAPTCHA_SITE_KEY = "fake" - result = assert_api_call( - client, - "psa-register-email", { - "flow": SocialAuthState.FLOW_REGISTER, - "email": NEW_EMAIL, - "recaptcha": "fake", + "flow": SocialAuthState.FLOW_LOGIN, + "state": SocialAuthState.STATE_LOGIN_PASSWORD, + "extra_data": {"name": self.user.name}, }, - {"error-codes": ["bad-request"], "success": False}, - expect_status=status.HTTP_400_BAD_REQUEST, - use_defaults=False, ) - mock_recaptcha_failure.assert_called_once() - mock_email_send.assert_not_called() - return result - - yield run_step - -@pytest.fixture() -def login_password_valid(client, user): - """Yield a function for this step""" - password = "password1" - - def run_step(last_result): - """Run the step""" - user.set_password(password) - user.save() - return assert_api_call( - client, + @rule(auth_state=consumes(LoginPasswordAuthStates)) + def login_password_valid(self, auth_state): + """Login with an invalid password""" + password = "password123" + self.user.set_password(password) + self.user.save() + assert_api_call( + self.client, "psa-login-password", { - "flow": SocialAuthState.FLOW_LOGIN, - "partial_token": last_result["partial_token"], + "flow": auth_state["flow"], + "partial_token": auth_state["partial_token"], "password": password, }, { - "flow": SocialAuthState.FLOW_LOGIN, - "provider": EmailAuth.name, + "flow": auth_state["flow"], "redirect_url": NEXT_URL, "partial_token": None, "state": SocialAuthState.STATE_SUCCESS, @@ -302,130 +334,104 @@ def run_step(last_result): expect_authenticated=True, ) - yield run_step - - -@pytest.fixture() -def login_password_user_inactive(client, user): - """Yield a function for this step""" - password = "password1" - - def run_step(last_result): - """Run the step""" - user.is_active = False - user.set_password(password) - user.save() + @rule(target=LoginPasswordAuthStates, auth_state=consumes(LoginPasswordAuthStates)) + def login_password_invalid(self, auth_state): + """Login with an invalid password""" + self.user.set_password("password1") + self.user.save() return assert_api_call( - client, + self.client, "psa-login-password", { - "flow": SocialAuthState.FLOW_LOGIN, - "partial_token": last_result["partial_token"], - "password": password, + "flow": auth_state["flow"], + "partial_token": auth_state["partial_token"], + "password": "invalidpass", }, { - "flow": SocialAuthState.FLOW_LOGIN, - "provider": EmailAuth.name, - "partial_token": None, - "state": SocialAuthState.STATE_INACTIVE, + "errors": ["Unable to login with that email and password combination"], + "flow": auth_state["flow"], + "state": SocialAuthState.STATE_ERROR, }, ) - yield run_step + @rule(auth_state=consumes(LoginPasswordAuthStates)) + def login_password_user_inactive(self, auth_state): + """Login for an inactive user""" + password = "password1" + self.user.is_active = False + self.user.set_password(password) + self.user.save() -@pytest.fixture() -def login_password_invalid(client, user): - """Yield a function for this step""" - - def run_step(last_result): - """Run the step""" - user.set_password("password1") - user.save() - return assert_api_call( - client, + assert_api_call( + self.client, "psa-login-password", { - "flow": SocialAuthState.FLOW_LOGIN, - "partial_token": last_result["partial_token"], - "password": "invalidpass", + "flow": auth_state["flow"], + "partial_token": auth_state["partial_token"], + "password": password, }, { - "errors": ["Unable to login with that email and password combination"], - "flow": SocialAuthState.FLOW_LOGIN, - "provider": EmailAuth.name, - "state": SocialAuthState.STATE_ERROR, + "flow": auth_state["flow"], + "partial_token": None, + "state": SocialAuthState.STATE_INACTIVE, }, ) - yield run_step - - -@pytest.fixture() -def redeem_confirmation_code(client, mock_email_send): - """Yield a function for this step""" - - def run_step(last_result): # pylint: disable=unused-argument - """Run the step""" - _, _, code, partial_token = mock_email_send.call_args[0] + @rule( + target=ConfirmationRedeemedAuthStates, + auth_state=consumes(ConfirmationSentAuthStates), + ) + def redeem_confirmation_code(self, auth_state): + """Redeem a registration confirmation code""" + _, _, code, partial_token = self.mock_email_send.call_args[0] return assert_api_call( - client, + self.client, "psa-register-confirm", { - "flow": SocialAuthState.FLOW_REGISTER, + "flow": auth_state["flow"], "verification_code": code.code, "partial_token": partial_token, }, { - "flow": SocialAuthState.FLOW_REGISTER, - "provider": EmailAuth.name, + "flow": auth_state["flow"], "state": SocialAuthState.STATE_REGISTER_DETAILS, }, ) - yield run_step - - -@pytest.fixture() -def redeem_confirmation_code_twice(client, mock_email_send): - """Yield a function for this step""" - - def run_step(last_result): # pylint: disable=unused-argument - """Run the step""" - _, _, code, partial_token = mock_email_send.call_args[0] - return assert_api_call( - client, + @rule(auth_state=consumes(ConfirmationRedeemedAuthStates)) + def redeem_confirmation_code_twice(self, auth_state): + """Redeeming a code twice should fail""" + _, _, code, partial_token = self.mock_email_send.call_args[0] + assert_api_call( + self.client, "psa-register-confirm", { - "flow": SocialAuthState.FLOW_REGISTER, + "flow": auth_state["flow"], "verification_code": code.code, "partial_token": partial_token, }, { "errors": [], - "flow": SocialAuthState.FLOW_REGISTER, - "provider": EmailAuth.name, + "flow": auth_state["flow"], "redirect_url": None, "partial_token": None, "state": SocialAuthState.STATE_INVALID_EMAIL, }, ) - yield run_step - - -@pytest.fixture() -def register_user_details(client): - """Yield a function for this step""" - - def run_step(last_result): - """Run the step""" + @rule( + target=RegisterExtraDetailsAuthStates, + auth_state=consumes(ConfirmationRedeemedAuthStates), + ) + def register_details(self, auth_state): + """Complete the register confirmation details page""" return assert_api_call( - client, + self.client, "psa-register-details", { - "flow": SocialAuthState.FLOW_REGISTER, - "partial_token": last_result["partial_token"], + "flow": auth_state["flow"], + "partial_token": auth_state["partial_token"], "password": "password1", "name": "Sally Smith", "legal_address": { @@ -439,85 +445,162 @@ def run_step(last_result): }, }, { - "flow": SocialAuthState.FLOW_REGISTER, - "provider": EmailAuth.name, + "flow": auth_state["flow"], "state": SocialAuthState.STATE_REGISTER_EXTRA_DETAILS, }, - expect_authenticated=False, ) - yield run_step - - -@pytest.fixture() -def register_user_extra_details(client): - """Yield a function for this step""" - - def run_step(last_result): - """Run the step""" - return assert_api_call( - client, + @rule( + target=RegisterExtraDetailsAuthStates, + auth_state=consumes(ConfirmationRedeemedAuthStates), + ) + def register_details_export_success(self, auth_state): + """Complete the register confirmation details page with exports enabled""" + with export_check_response("100_success"): + result = assert_api_call( + self.client, + "psa-register-details", + { + "flow": auth_state["flow"], + "partial_token": auth_state["partial_token"], + "password": "password1", + "name": "Sally Smith", + "legal_address": { + "first_name": "Sally", + "last_name": "Smith", + "street_address": ["Main Street"], + "country": "US", + "state_or_territory": "US-CO", + "city": "Boulder", + "postal_code": "02183", + }, + }, + { + "flow": auth_state["flow"], + "state": SocialAuthState.STATE_REGISTER_EXTRA_DETAILS, + }, + ) + assert ExportsInquiryLog.objects.filter(user__email=self.email).exists() + assert ( + ExportsInquiryLog.objects.get(user__email=self.email).computed_result + == RESULT_SUCCESS + ) + assert len(mail.outbox) == 0 + + return result + + @rule(auth_state=consumes(ConfirmationRedeemedAuthStates)) + def register_details_export_reject(self, auth_state): + """Complete the register confirmation details page with exports enabled""" + with export_check_response("700_reject"): + assert_api_call( + self.client, + "psa-register-details", + { + "flow": auth_state["flow"], + "partial_token": auth_state["partial_token"], + "password": "password1", + "name": "Sally Smith", + "legal_address": { + "first_name": "Sally", + "last_name": "Smith", + "street_address": ["Main Street"], + "country": "US", + "state_or_territory": "US-CO", + "city": "Boulder", + "postal_code": "02183", + }, + }, + { + "flow": auth_state["flow"], + "partial_token": None, + "errors": [ + "Unable to complete registration, please contact support" + ], + "state": SocialAuthState.STATE_USER_BLOCKED, + }, + ) + assert ExportsInquiryLog.objects.filter(user__email=self.email).exists() + assert ( + ExportsInquiryLog.objects.get(user__email=self.email).computed_result + == RESULT_DENIED + ) + assert len(mail.outbox) == 1 + + @rule(auth_state=consumes(ConfirmationRedeemedAuthStates)) + def register_details_export_temporary_error(self, auth_state): + """Complete the register confirmation details page with exports raising a temporary error""" + with override_settings(**get_cybersource_test_settings()), patch( + "authentication.pipeline.compliance.api.verify_user_with_exports", + side_effect=Exception("register_details_export_temporary_error"), + ): + assert_api_call( + self.client, + "psa-register-details", + { + "flow": auth_state["flow"], + "partial_token": auth_state["partial_token"], + "password": "password1", + "name": "Sally Smith", + "legal_address": { + "first_name": "Sally", + "last_name": "Smith", + "street_address": ["Main Street"], + "country": "US", + "state_or_territory": "US-CO", + "city": "Boulder", + "postal_code": "02183", + }, + }, + { + "flow": auth_state["flow"], + "partial_token": None, + "errors": [ + "Unable to register at this time, please try again later" + ], + "state": SocialAuthState.STATE_ERROR_TEMPORARY, + }, + ) + assert not ExportsInquiryLog.objects.filter(user__email=self.email).exists() + assert len(mail.outbox) == 0 + + @rule(auth_state=consumes(RegisterExtraDetailsAuthStates)) + def register_user_extra_details(self, auth_state): + """Complete the user's extra details""" + assert_api_call( + Client(), "psa-register-extra", { - "flow": SocialAuthState.FLOW_REGISTER, - "partial_token": last_result["partial_token"], + "flow": auth_state["flow"], + "partial_token": auth_state["partial_token"], "gender": "f", "birth_year": "2000", "company": "MIT", "job_title": "QA Manager", }, { - "flow": SocialAuthState.FLOW_REGISTER, - "provider": EmailAuth.name, + "flow": auth_state["flow"], "state": SocialAuthState.STATE_SUCCESS, "partial_token": None, }, expect_authenticated=True, ) - yield run_step - -@pytest.mark.betamax -@pytest.mark.usefixture("mock_email_send") -@pytest.mark.parametrize( - "steps", - [ - ["login_email_exists", "login_password_valid"], - ["login_email_exists", "login_password_invalid"], - ["login_email_exists", "login_password_user_inactive"], - ["login_email_not_exists"], - ["register_email_exists", "login_password_valid"], - ["register_email_exists", "login_password_invalid"], - [ - "register_email_not_exists", - "redeem_confirmation_code", - "register_user_details", - "register_user_extra_details", - ], - [ - "register_email_not_exists_with_recaptcha", - "redeem_confirmation_code", - "register_user_details", - "register_user_extra_details", - ], - [ - "register_email_not_exists", - "redeem_confirmation_code", - "redeem_confirmation_code_twice", - ], - ["register_email_not_exists_with_recaptcha_invalid"], - ], - ids=lambda arg: "->".join(arg) if isinstance(arg, list) else None, +AuthStateMachine.TestCase.settings = hypothesis_settings( + max_examples=100, + stateful_step_count=10, + deadline=None, + verbosity=Verbosity.normal, + suppress_health_check=[HealthCheck.filter_too_much], ) -def test_login_register_flows(request, steps): - """Walk the steps and assert expected results""" - last_result = None - for fixture_name in steps: - assert_step = request.getfixturevalue(fixture_name) - last_result = assert_step(last_result) +class AuthStateTestCase(HTestCase, AuthStateMachine.TestCase): + """TestCase for AuthStateMachine""" + + +@pytest.mark.usefixtures("mock_email_send") def test_new_register_no_session_partial(client): """ When a user registers for the first time and a verification email is sent, the partial @@ -530,7 +613,6 @@ def test_new_register_no_session_partial(client): {"flow": SocialAuthState.FLOW_REGISTER, "email": NEW_EMAIL}, { "flow": SocialAuthState.FLOW_REGISTER, - "provider": EmailAuth.name, "partial_token": None, "state": SocialAuthState.STATE_REGISTER_CONFIRM_SENT, }, diff --git a/cms/factories.py b/cms/factories.py index b2bbc833f..ee4fd610b 100644 --- a/cms/factories.py +++ b/cms/factories.py @@ -16,6 +16,7 @@ ResourcePage, UserTestimonialsPage, FacultyMembersPage, + SiteNotification, ) from cms.blocks import ( LearningTechniqueBlock, @@ -198,3 +199,12 @@ class FacultyMembersPageFactory(wagtail_factories.PageFactory): class Meta: model = FacultyMembersPage + + +class SiteNotificationFactory(DjangoModelFactory): + """SiteNotification factory class""" + + message = factory.fuzzy.FuzzyText(prefix="message ") + + class Meta: + model = SiteNotification diff --git a/cms/migrations/0017_sections_generalize.py b/cms/migrations/0017_sections_generalize.py new file mode 100644 index 000000000..c31bb6ebc --- /dev/null +++ b/cms/migrations/0017_sections_generalize.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1.7 on 2019-05-20 13:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [("cms", "0016_faculty_members_subpage")] + + operations = [ + migrations.AlterModelOptions( + name="forteamspage", options={"verbose_name": "Text-Image Section"} + ), + migrations.AlterModelOptions( + name="learningtechniquespage", options={"verbose_name": "Icon Grid"} + ), + migrations.AlterModelOptions( + name="usertestimonialspage", + options={"verbose_name": "Testimonials Section"}, + ), + ] diff --git a/cms/migrations/0018_sitenotification.py b/cms/migrations/0018_sitenotification.py new file mode 100644 index 000000000..7e8d9ef6d --- /dev/null +++ b/cms/migrations/0018_sitenotification.py @@ -0,0 +1,27 @@ +# Generated by Django 2.1.7 on 2019-05-22 06:15 + +from django.db import migrations, models +import wagtail.core.fields + + +class Migration(migrations.Migration): + + dependencies = [("cms", "0017_sections_generalize")] + + operations = [ + migrations.CreateModel( + name="SiteNotification", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("message", wagtail.core.fields.RichTextField(max_length=255)), + ], + ) + ] diff --git a/cms/models.py b/cms/models.py index a1d524003..0c495f61b 100644 --- a/cms/models.py +++ b/cms/models.py @@ -18,6 +18,8 @@ from wagtail.core.blocks import RawHTMLBlock from wagtail.images.models import Image from wagtail.images.blocks import ImageChooserBlock +from wagtail.snippets.models import register_snippet + from modelcluster.fields import ParentalKey @@ -53,7 +55,8 @@ def can_create_at(cls, parent): def save(self, *args, **kwargs): # autogenerate a unique slug so we don't hit a ValidationError - self.title = self.__class__._meta.verbose_name.title() + if not self.title: + self.title = self.__class__._meta.verbose_name.title() self.slug = slugify("{}-{}".format(self.get_parent().id, self.title)) super().save(*args, **kwargs) @@ -82,7 +85,7 @@ class UserTestimonialsPage(CourseProgramChildPage): ] class Meta: - verbose_name = "Testimonials Page" + verbose_name = "Testimonials Section" class LearningOutcomesPage(CourseProgramChildPage): @@ -128,7 +131,10 @@ class LearningTechniquesPage(CourseProgramChildPage): help_text="Enter detail about how you'll learn.", ) - content_panels = [StreamFieldPanel("technique_items")] + class Meta: + verbose_name = "Icon Grid" + + content_panels = [FieldPanel("title"), StreamFieldPanel("technique_items")] class ForTeamsPage(CourseProgramChildPage): @@ -153,7 +159,12 @@ class ForTeamsPage(CourseProgramChildPage): related_name="+", help_text="Image size must be at least 750x505 pixels.", ) + + class Meta: + verbose_name = "Text-Image Section" + content_panels = [ + FieldPanel("title"), FieldPanel("content"), FieldPanel("action_title"), FieldPanel("switch_layout"), @@ -447,3 +458,17 @@ def save(self, *args, **kwargs): self.slug = "%s-%d" % (original_slug, x) super().save(*args, **kwargs) + + +@register_snippet +class SiteNotification(models.Model): + """ Snippet model for showing site notifications. """ + + message = RichTextField( + max_length=255, features=["bold", "italic", "link", "document-link"] + ) + + panels = [FieldPanel("message")] + + def __str__(self): + return self.message diff --git a/cms/models_test.py b/cms/models_test.py index 84cdd7ed9..586a71def 100644 --- a/cms/models_test.py +++ b/cms/models_test.py @@ -3,7 +3,7 @@ import json import pytest -from cms.factories import ResourcePageFactory +from cms.factories import ResourcePageFactory, SiteNotificationFactory pytestmark = [pytest.mark.django_db] @@ -44,3 +44,13 @@ def test_resource_page_unique_slug(): page = ResourcePageFactory(title="title of the page") another_page = ResourcePageFactory(title="title of the page") assert page.slug != another_page.slug + + +def test_notification_snippet(): + """ + Verify that user can create site notification using cms. + """ + message_text = "

hello this is a test notification

" + notification = SiteNotificationFactory(message=message_text) + + assert str(notification) == message_text diff --git a/compliance/__init__.py b/compliance/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/compliance/api.py b/compliance/api.py new file mode 100644 index 000000000..fa67c4b75 --- /dev/null +++ b/compliance/api.py @@ -0,0 +1,197 @@ +"""Compliance API""" +import logging + +from django.conf import settings +from lxml import etree +from nacl.encoding import Base64Encoder +from nacl.public import PublicKey, SealedBox +from zeep import Client +from zeep.plugins import HistoryPlugin +from zeep.wsse.username import UsernameToken + +from compliance.constants import ( + REASON_CODE_SUCCESS, + EXPORTS_BLOCKED_REASON_CODES, + TEMPORARY_FAILURE_REASON_CODES, + RESULT_DENIED, + RESULT_SUCCESS, + RESULT_UNKNOWN, +) +from compliance.models import ExportsInquiryLog + + +log = logging.getLogger() + + +EXPORTS_REQUIRED_KEYS = [ + "CYBERSOURCE_WSDL_URL", + "CYBERSOURCE_MERCHANT_ID", + "CYBERSOURCE_TRANSACTION_KEY", + "CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY", +] + + +def is_exports_verification_enabled(): + """Returns True if the exports verification is configured""" + return all(getattr(settings, key) for key in EXPORTS_REQUIRED_KEYS) + + +def get_cybersource_client(): + """ + Configures and authenticates a CyberSource client + + Returns: + (zeep.Client, zeep.plugins.HistoryPlugin): + a tuple of the configured client and the history plugin instance + """ + wsse = UsernameToken( + settings.CYBERSOURCE_MERCHANT_ID, settings.CYBERSOURCE_TRANSACTION_KEY + ) + history = HistoryPlugin() + client = Client(settings.CYBERSOURCE_WSDL_URL, wsse=wsse, plugins=[history]) + return client, history + + +def compute_result_from_codes(reason_code, info_code): + """ + Determines the result from the reason and info codes + + Args: + reason_code (int): the reasonCode returned from CyberSource + info_code (str): the infoCode returned from CyberSource + + Returns: + str: + the computed result + """ + # if there's either an explicit denial or any block list was triggered + # NOTE: reason_code can indicate a success but a block list still be triggered and indicated in info_code + if reason_code in EXPORTS_BLOCKED_REASON_CODES or info_code: + return RESULT_DENIED + + # a success with no red flags whatsoever + if reason_code == REASON_CODE_SUCCESS: + return RESULT_SUCCESS + + # failed to process an unknown reasonCode + log.error( + "Unable to verify exports controls, received unknown reasonCode: %s", + reason_code, + ) + return RESULT_UNKNOWN + + +def get_encryption_public_key(): + """Returns the public key for encryption of export requests/responses""" + return PublicKey( + settings.CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY, encoder=Base64Encoder + ) + + +def log_exports_inquiry(user, response, last_sent, last_received): + """ + Log a request/response for an export inquiry for a given user + + Args: + user (users.models.User): the user that was checked for exports compliance + response (etree.Element): the root response node from the API call + last_sent (dict): the raw request sent for this call + last_received (dict): the raw response received for this call + + Returns: + ExportsInquiryLog: the generate log record of the exports inquiry + """ + # render lxml data structures into a string so we can encrypt it + xml_request = etree.tostring(last_sent["envelope"]) + xml_response = etree.tostring(last_received["envelope"]) + + log.debug("Sent: %s", xml_request) + log.debug("Received: %s", xml_response) + + # overall status code of the response + # NOTE: reason_code can indicate a success but a block list still be triggered and indicated in info_code + reason_code = int(response.reasonCode) + + if reason_code in TEMPORARY_FAILURE_REASON_CODES: + # if it's a temporary failure in the CyberSource backend or + # the request itself, no point in recording this + log.error( + "Unable to verify exports controls, received reasonCode: %s", reason_code + ) + return None + + # if the data triggered a block list this will be truthy + info_code = response.exportReply.infoCode + + box = SealedBox(get_encryption_public_key()) + encrypted_request = box.encrypt(xml_request, encoder=Base64Encoder).decode("ascii") + encrypted_response = box.encrypt(xml_response, encoder=Base64Encoder).decode( + "ascii" + ) + + return ExportsInquiryLog.objects.create( + user=user, + computed_result=compute_result_from_codes(reason_code, info_code), + reason_code=reason_code, + info_code=info_code, + encrypted_request=encrypted_request, + encrypted_response=encrypted_response, + ) + + +def get_bill_to_address(user): + """ + Create an address appropriate to pass to billTo on the CyberSource API + + Args: + user (users.models.User): the user whose address to use + + Returns: + dict: + User's legal_address in the appropriate data structure + """ + + legal_address = user.legal_address + + # minimally require fields + billing_address = { + "firstName": legal_address.first_name, + "lastName": legal_address.last_name, + "email": user.email, + "street1": legal_address.street_address_1, + "street2": legal_address.street_address_2, + "street3": legal_address.street_address_3, + "street4": legal_address.street_address_4, + "city": legal_address.city, + "country": legal_address.country, + } + + # these are required for certain countries, we presume here that data was validated before it was written + if legal_address.state_or_territory: + # State is in US-MA format and we want that send part + billing_address["state"] = legal_address.state_or_territory.split("-")[1] + + if legal_address.postal_code: + billing_address["postalCode"] = legal_address.postal_code + + return billing_address + + +def verify_user_with_exports(user): + """Verify the user against the CyberSource exports service""" + client, history = get_cybersource_client() + + payload = { + "merchantID": settings.CYBERSOURCE_MERCHANT_ID, + "merchantReferenceCode": user.id, + "billTo": get_bill_to_address(user), + "exportService": { + "run": "true", # NOTE: *must* be a string otherwise it will serialize incorrectly to "True" + "addressOperator": settings.CYBERSOURCE_EXPORT_SERVICE_ADDRESS_OPERATOR, + "addressWeight": settings.CYBERSOURCE_EXPORT_SERVICE_ADDRESS_WEIGHT, + "nameWeight": settings.CYBERSOURCE_EXPORT_SERVICE_NAME_WEIGHT, + }, + } + response = client.service.runTransaction(**payload) + + return log_exports_inquiry(user, response, history.last_sent, history.last_received) diff --git a/compliance/api_test.py b/compliance/api_test.py new file mode 100644 index 000000000..c0280623d --- /dev/null +++ b/compliance/api_test.py @@ -0,0 +1,96 @@ +"""Tests for compliance api""" + +# pylint: disable=redefined-outer-name +import pytest +from lxml import etree +from nacl.encoding import Base64Encoder +from nacl.public import SealedBox + +from compliance import api +from compliance.constants import ( + RESULT_SUCCESS, + RESULT_DENIED, + RESULT_UNKNOWN, + TEMPORARY_FAILURE_REASON_CODES, +) +from compliance.models import ExportsInquiryLog + + +@pytest.mark.usefixtures("cybersource_settings") +def test_is_exports_verification_enabled(): + """Test that is_exports_verification_enabled is true if all settings set""" + assert api.is_exports_verification_enabled() is True + + +@pytest.mark.usefixtures("cybersource_settings") +@pytest.mark.parametrize("key", api.EXPORTS_REQUIRED_KEYS) +def test_is_exports_verification_disabled(settings, key): + """Test that is_exports_verification_enabled is false if a setting is missing""" + setattr(settings, key, None) + assert api.is_exports_verification_enabled() is False + + +@pytest.mark.usefixtures("cybersource_settings") +def test_log_exports_inquiry(mocker, cybersource_private_key, user): + """Test that log_exports_inquiry correctly stores the result""" + last_sent = {"envelope": etree.Element("sent")} + last_received = {"envelope": etree.Element("received")} + mock_response = mocker.Mock( + reasonCode="100", exportReply=mocker.Mock(infoCode="102") + ) + log = api.log_exports_inquiry(user, mock_response, last_sent, last_received) + + assert log.user == user + assert log.reason_code == 100 + assert log.info_code == "102" + + box = SealedBox(cybersource_private_key) + + assert box.decrypt(log.encrypted_request, encoder=Base64Encoder) == b"" + assert box.decrypt(log.encrypted_response, encoder=Base64Encoder) == b"" + + +@pytest.mark.parametrize( + "cybersource_mock_client_responses, expected_result", + [ + ["700_reject", RESULT_DENIED], + ["100_success_match", RESULT_DENIED], + ["100_success", RESULT_SUCCESS], + ["978_unknown", RESULT_UNKNOWN], + ], + indirect=["cybersource_mock_client_responses"], +) +def test_verify_user_with_exports( + user, cybersource_mock_client_responses, expected_result +): # pylint: disable=unused-argument + """Test that verify_user_with_exports handles""" + result = api.verify_user_with_exports(user) + + assert result.computed_result == expected_result + + assert ExportsInquiryLog.objects.filter(user=user).exists() + + +@pytest.mark.usefixtures("cybersource_settings") +@pytest.mark.parametrize("reason_code", TEMPORARY_FAILURE_REASON_CODES) +def test_verify_user_with_exports_temporary_errors(mocker, user, reason_code): + """Verify no result is recorded if the nature of the error is temporary""" + mock_log = mocker.patch("compliance.api.log") + # + mock_client = mocker.Mock() + mock_client.service.runTransaction.return_value.reasonCode = str(reason_code) + # create a history with some dummy lxml objects + mock_history = mocker.Mock( + last_sent={"envelope": etree.Element("sent")}, + last_received={"envelope": etree.Element("received")}, + ) + with mocker.patch( + "compliance.api.get_cybersource_client", + return_value=(mock_client, mock_history), + ): + assert api.verify_user_with_exports(user) is None + mock_log.error.assert_called_once_with( + "Unable to verify exports controls, received reasonCode: %s", reason_code + ) + + assert not ExportsInquiryLog.objects.filter(user=user).exists() diff --git a/compliance/apps.py b/compliance/apps.py new file mode 100644 index 000000000..51b9ac621 --- /dev/null +++ b/compliance/apps.py @@ -0,0 +1,8 @@ +"""Compliance app""" +from django.apps import AppConfig + + +class ComplianceConfig(AppConfig): + """Compliance AppConfig""" + + name = "compliance" diff --git a/compliance/constants.py b/compliance/constants.py new file mode 100644 index 000000000..5d09c45f9 --- /dev/null +++ b/compliance/constants.py @@ -0,0 +1,60 @@ +"""Compliance constants""" + +# computed result states +RESULT_SUCCESS = "SUCCESS" +RESULT_MANUALLY_APPROVED = "MANUALLY_APPROVED" +RESULT_DENIED = "DENIED" +RESULT_TEMPORARY_FAILURE = "TEMPORARY_FAILURE" +RESULT_UNKNOWN = "UNKNOWN" +RESULT_CHOICES = ( + RESULT_SUCCESS, + RESULT_MANUALLY_APPROVED, + RESULT_DENIED, + RESULT_TEMPORARY_FAILURE, + RESULT_UNKNOWN, +) + +# REASON CODES + +# "SUCCESS" +REASON_CODE_SUCCESS = 100 + +# INPUT ERRORS +REASON_CODE_MISSING_FIELDS = 101 +REASON_CODE_INVALID_FIELDS = 102 + +# BACKEND FAILURES +REASON_CODE_GENERAL_FAILURE = 150 +REASON_CODE_SERVER_TIMEOUT = 151 +REASON_CODE_SERVICE_TIMEOUT = 152 + +# CONFIGURATION FAILURES +REASON_CODE_MERCHANT_CONFIG_ERROR = 234 + +# EXPORTS DENIED +REASON_CODE_EMBARGO_CUSTOMER = 700 +REASON_CODE_EMBARGO_COUNTRY = 701 +REASON_CODE_EMBARGO_COUNTRY_EMAIL = 702 +REASON_CODE_EMBARGO_COUNTRY_BY_IP = 703 + +# REASON CODE SETS +INVALID_REQUEST_REASON_CODES = [REASON_CODE_MISSING_FIELDS, REASON_CODE_INVALID_FIELDS] +CYBERSOURCE_BACKEND_FAILURE_REASON_CODES = [ + REASON_CODE_GENERAL_FAILURE, + REASON_CODE_SERVER_TIMEOUT, + REASON_CODE_SERVICE_TIMEOUT, +] +CYBERSOURCE_CONFIG_ERROR_REASON_CODES = [REASON_CODE_MERCHANT_CONFIG_ERROR] + +TEMPORARY_FAILURE_REASON_CODES = ( + INVALID_REQUEST_REASON_CODES + + CYBERSOURCE_BACKEND_FAILURE_REASON_CODES + + CYBERSOURCE_CONFIG_ERROR_REASON_CODES +) + +EXPORTS_BLOCKED_REASON_CODES = [ + REASON_CODE_EMBARGO_CUSTOMER, + REASON_CODE_EMBARGO_COUNTRY, + REASON_CODE_EMBARGO_COUNTRY_EMAIL, + REASON_CODE_EMBARGO_COUNTRY_BY_IP, +] diff --git a/compliance/factories.py b/compliance/factories.py new file mode 100644 index 000000000..b3b34cc7c --- /dev/null +++ b/compliance/factories.py @@ -0,0 +1,27 @@ +"""Factories for compliance app""" +from factory import Faker, SubFactory, Trait +from factory.django import DjangoModelFactory +from factory.fuzzy import FuzzyChoice + +from compliance.constants import RESULT_DENIED, RESULT_SUCCESS, RESULT_CHOICES +from compliance.models import ExportsInquiryLog + + +class ExportsInquiryLogFactory(DjangoModelFactory): + """Factory for ExportsInquiryLog""" + + user = SubFactory("users.factories.UserFactory") + computed_result = FuzzyChoice(RESULT_CHOICES) + + reason_code = Faker("pyint") + info_code = Faker("numerify", text="###") + + encrypted_request = Faker("pystr", max_chars=30) + encrypted_response = Faker("pystr", max_chars=30) + + class Meta: + model = ExportsInquiryLog + + class Params: + denied = Trait(computed_result=RESULT_DENIED) + success = Trait(computed_result=RESULT_SUCCESS) diff --git a/compliance/migrations/0001_add_export_inquiry_log.py b/compliance/migrations/0001_add_export_inquiry_log.py new file mode 100644 index 000000000..ff649de58 --- /dev/null +++ b/compliance/migrations/0001_add_export_inquiry_log.py @@ -0,0 +1,57 @@ +# Generated by Django 2.1.7 on 2019-05-10 13:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + + operations = [ + migrations.CreateModel( + name="ExportsInquiryLog", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ( + "computed_result", + models.CharField( + choices=[ + ("SUCCESS", "SUCCESS"), + ("MANUALLY_APPROVED", "MANUALLY_APPROVED"), + ("DENIED", "DENIED"), + ("TEMPORARY_FAILURE", "TEMPORARY_FAILURE"), + ("UNKNOWN", "UNKNOWN"), + ], + max_length=30, + ), + ), + ("reason_code", models.IntegerField()), + ("info_code", models.CharField(max_length=255, null=True)), + ("encrypted_request", models.TextField()), + ("encrypted_response", models.TextField()), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="exports_inquiries", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"abstract": False}, + ) + ] diff --git a/compliance/migrations/__init__.py b/compliance/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/compliance/models.py b/compliance/models.py new file mode 100644 index 000000000..3fda83447 --- /dev/null +++ b/compliance/models.py @@ -0,0 +1,49 @@ +"""Compliance app models""" +from django.conf import settings +from django.db import models + +from compliance.constants import ( + RESULT_CHOICES, + RESULT_SUCCESS, + RESULT_DENIED, + RESULT_UNKNOWN, + RESULT_MANUALLY_APPROVED, +) +from mitxpro.models import TimestampedModel + + +class ExportsInquiryLog(TimestampedModel): + """ + Model to track exports exports_inquiries + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="exports_inquiries", + ) + + computed_result = models.CharField( + max_length=30, choices=zip(RESULT_CHOICES, RESULT_CHOICES) + ) + + reason_code = models.IntegerField() + info_code = models.CharField(max_length=255, null=True) + + encrypted_request = models.TextField() + encrypted_response = models.TextField() + + @property + def is_denied(self): + """Returns true if the export result was denied""" + return self.computed_result == RESULT_DENIED + + @property + def is_success(self): + """Returns true if the export result was a success""" + return self.computed_result in (RESULT_SUCCESS, RESULT_MANUALLY_APPROVED) + + @property + def is_unknown(self): + """Returns true if the export result was unknown""" + return self.computed_result == RESULT_UNKNOWN diff --git a/compliance/test_data/cybersource/100_success.xml b/compliance/test_data/cybersource/100_success.xml new file mode 100644 index 000000000..51632fb4a --- /dev/null +++ b/compliance/test_data/cybersource/100_success.xml @@ -0,0 +1,21 @@ + + + + + 2019-05-09T18:21:31.312Z + + + + + + example + 123 + SUCCESS + 100 + TOKEN + + 100 + + + + diff --git a/compliance/test_data/cybersource/100_success_match.xml b/compliance/test_data/cybersource/100_success_match.xml new file mode 100644 index 000000000..5fb49808d --- /dev/null +++ b/compliance/test_data/cybersource/100_success_match.xml @@ -0,0 +1,34 @@ + + + + + 2019-05-09T18:21:31.312Z + + + + + + example + 123 + SUCCESS + 100 + TOKEN + + Office of Foreign Assets Control + Mohammed QASIM + aka, QASIM, Muhammad + ,Waish,Spin Boldak,null, + Bypass Road,Chaman,,null, + Karez Qaran,Musa Qal'ah,Helmand Province,null, + Qalaye Haji Ali Akbar Dalbandin Post Office,,,null, + Room 33, 5th Floor Sarafi Market,Kandahar,,null, + Safaar Bazaar,Garmsir,,null, + SDGT + + + 100 + MATCH-DPC + + + + diff --git a/compliance/test_data/cybersource/151_timeout.xml b/compliance/test_data/cybersource/151_timeout.xml new file mode 100644 index 000000000..8b7f5b5fa --- /dev/null +++ b/compliance/test_data/cybersource/151_timeout.xml @@ -0,0 +1,21 @@ + + + + + 2019-05-09T18:21:31.312Z + + + + + + example + 123 + ERROR + 151 + TOKEN + + 151 + + + + diff --git a/compliance/test_data/cybersource/700_reject.xml b/compliance/test_data/cybersource/700_reject.xml new file mode 100644 index 000000000..475d8e7e9 --- /dev/null +++ b/compliance/test_data/cybersource/700_reject.xml @@ -0,0 +1,34 @@ + + + + + 2019-05-09T18:21:31.312Z + + + + + + example + 123 + REJECT + 700 + TOKEN + + Office of Foreign Assets Control + Mohammed QASIM + aka, QASIM, Muhammad + ,Waish,Spin Boldak,null, + Bypass Road,Chaman,,null, + Karez Qaran,Musa Qal'ah,Helmand Province,null, + Qalaye Haji Ali Akbar Dalbandin Post Office,,,null, + Room 33, 5th Floor Sarafi Market,Kandahar,,null, + Safaar Bazaar,Garmsir,,null, + SDGT + + + 700 + MATCH-DPC + + + + diff --git a/compliance/test_data/cybersource/978_unknown.xml b/compliance/test_data/cybersource/978_unknown.xml new file mode 100644 index 000000000..1c6fb1f2d --- /dev/null +++ b/compliance/test_data/cybersource/978_unknown.xml @@ -0,0 +1,21 @@ + + + + + 2019-05-09T18:21:31.312Z + + + + + + example + 123 + SUCCESS + 978 + TOKEN + + 978 + + + + diff --git a/compliance/test_data/cybersource/CyberSourceTransaction_1.154.wsdl b/compliance/test_data/cybersource/CyberSourceTransaction_1.154.wsdl new file mode 100644 index 000000000..7c588586f --- /dev/null +++ b/compliance/test_data/cybersource/CyberSourceTransaction_1.154.wsdl @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FOR PYTEST ONLY + + + + + diff --git a/compliance/test_data/cybersource/CyberSourceTransaction_1.154.xsd b/compliance/test_data/cybersource/CyberSourceTransaction_1.154.xsd new file mode 100644 index 000000000..89ed4b2be --- /dev/null +++ b/compliance/test_data/cybersource/CyberSourceTransaction_1.154.xsd @@ -0,0 +1,4840 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/compliance/test_utils.py b/compliance/test_utils.py new file mode 100644 index 000000000..dcb85fa91 --- /dev/null +++ b/compliance/test_utils.py @@ -0,0 +1,61 @@ +"""Testing utils around CyberSource""" +from nacl.public import PrivateKey +from nacl.encoding import Base64Encoder +from rest_framework import status + +SERVICE_VERSION = "1.154" + +DATA_DIR = "compliance/test_data/cybersource" + + +def get_cybersource_test_settings(private_key=None): + """ + Generates a valid set of settings for CyberSource + """ + if private_key is None: + private_key = PrivateKey.generate() + + return { + "CYBERSOURCE_WSDL_URL": ( + f"http://localhost/service/CyberSourceTransaction_{SERVICE_VERSION}.wsdl" + ), + "CYBERSOURCE_MERCHANT_ID": "merchant_id", + "CYBERSOURCE_TRANSACTION_KEY": "transaction_key", + "CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY": Base64Encoder.encode( + bytes(private_key.public_key) + ), + } + + +def mock_cybersource_wsdl(mocked_responses, settings, service_version=SERVICE_VERSION): + """ + Mocks the responses to achieve a functional WSDL + """ + # in order for zeep to load the wsdl, it will load the wsdl and the accompanying xsd definitions + with open(f"{DATA_DIR}/CyberSourceTransaction_{service_version}.wsdl", "r") as wsdl: + mocked_responses.add( + mocked_responses.GET, + settings.CYBERSOURCE_WSDL_URL, + body=wsdl.read(), + status=status.HTTP_200_OK, + ) + with open(f"{DATA_DIR}/CyberSourceTransaction_{SERVICE_VERSION}.xsd", "r") as xsd: + mocked_responses.add( + mocked_responses.GET, + f"http://localhost/service/CyberSourceTransaction_{service_version}.xsd", + body=xsd.read(), + status=status.HTTP_200_OK, + ) + + +def mock_cybersource_wsdl_operation(mocked_responses, response_name): + """ + Mock the response for an operation + """ + with open(f"{DATA_DIR}/{response_name}.xml") as operation_response: + mocked_responses.add( + mocked_responses.POST, + "http://localhost/service", + body=operation_response.read(), + status=status.HTTP_200_OK, + ) diff --git a/conftest.py b/conftest.py index 7429e8559..b5f7bfb66 100644 --- a/conftest.py +++ b/conftest.py @@ -8,6 +8,7 @@ from fixtures.common import * from fixtures.autouse import * +from fixtures.cybersource import * TEST_MEDIA_ROOT = "/var/media/test_media_root" diff --git a/courses/models.py b/courses/models.py index eb64ddb1a..8d3d18489 100644 --- a/courses/models.py +++ b/courses/models.py @@ -6,6 +6,7 @@ from django.db import models from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericRelation +from django.urls import reverse from cms.models import ( LearningOutcomesPage, @@ -240,6 +241,20 @@ def course_lineup(self): """Gets the CoursesInProgram subpage if associated with this program""" return self._get_child_page_of_type(CoursesInProgramPage) + @property + def url(self): + """ + Gets the URL for this resource + """ + return NotImplementedError() + + @property + def type_name(self): + """ + Gets the descriptive word for the type of this resource + """ + return "program" + def __str__(self): return self.title @@ -314,6 +329,20 @@ def unexpired_runs(self): ) ) + @property + def url(self): + """ + Gets the URL for this resource + """ + return reverse("course-detail", kwargs={"pk": self.pk}) + + @property + def type_name(self): + """ + Gets the descriptive word for the type of this resource + """ + return "course" + class Meta: ordering = ("program", "title") diff --git a/courses/templates/course_detail.html b/courses/templates/course_detail.html index 924be1aec..0399a83b1 100644 --- a/courses/templates/course_detail.html +++ b/courses/templates/course_detail.html @@ -31,13 +31,15 @@ {% include "partials/subnav.html" with product=course %} {% if course.outcomes %} {% include "partials/learning-outcomes.html" %} {% endif %} {% if course.who_should_enroll %} {% include "partials/target-audience.html" %} {% endif %} - {% if course.techniques %} {% include "partials/learning-techniques.html" %} {% endif %} - {% if course.testimonials %} {% include "partials/testimonial-carousel.html" with testimonials=course.testimonials %} {% endif %} + {% if course.techniques %} {% include "partials/learning-techniques.html" with page=course.techniques %} {% endif %} + {% if course.testimonials %} {% include "partials/testimonial-carousel.html" with page=course.testimonials %} {% endif %} {% if course.faculty %} {% include "partials/faculty-carousel.html" %} {% endif %} {% if course.program and course.program.course_lineup %} - {% include "partials/course-carousel.html" with program=course.program %} + {% with courseware_objects=course.program.courses.all page=course.program.course_lineup %} + {% include "partials/course-carousel.html" with button_title="View Full Program" button_url="#" %} + {% endwith %} {% endif %} - {% if course.for_teams %} {% include "partials/for-teams.html" %} {% endif %} + {% if course.for_teams %} {% include "partials/for-teams.html" with page=course.for_teams %} {% endif %} {% if course.faqs %} {% include "partials/faqs.html" %} {% endif %} {% endblock %} diff --git a/courses/templates/partials/course-carousel.html b/courses/templates/partials/course-carousel.html index cb8173133..fbffccda7 100644 --- a/courses/templates/partials/course-carousel.html +++ b/courses/templates/partials/course-carousel.html @@ -2,26 +2,28 @@
-

{{ program.course_lineup.heading }}

- {{ program.course_lineup.body|safe }} +

{{ page.heading }}

+ {{ page.body|safe }}
- {% for child_course in program.courses.all %} + {% for courseware_object in courseware_objects %}
- {% if child_course.thumbnail_image %} - {% image child_course.thumbnail_image fill-480x275 as course_image %} - {{ child_course.display_title }} + {% if courseware_object.thumbnail_image %} + {% image courseware_object.thumbnail_image fill-480x275 as course_image %} + {{ courseware_object.display_title }} {% else %} - {{ child_course.display_title }} + {{ courseware_object.display_title }} {% endif %} -

{{ child_course.display_title }}

-

{{ child_course.subhead }}

- view course +

{{ courseware_object.display_title }}

+

{{ courseware_object.subhead }}

+ view {{ courseware_object.type_name }}
{% endfor %}
- View Full Program + {% if button_title and button_url %} + {{ button_title }} + {% endif %}
\ No newline at end of file diff --git a/courses/templates/partials/for-teams.html b/courses/templates/partials/for-teams.html index 7d8a31a25..af14d0aa1 100644 --- a/courses/templates/partials/for-teams.html +++ b/courses/templates/partials/for-teams.html @@ -2,15 +2,15 @@
-
-

For Teams

- {{ course.for_teams.content|safe }} - {{ course.for_teams.action_title }} +
+

{{ page.title }}

+ {{ page.content|safe }} + {{ page.action_title }}
-
- {% if course.for_teams.image %} - {% image course.for_teams.image fill-750x505 %} +
+ {% if page.image %} + {% image page.image fill-750x505 %} {% else %} For Teams {% endif%} diff --git a/courses/templates/partials/learning-techniques.html b/courses/templates/partials/learning-techniques.html index 285c6cc89..d8a9c6468 100644 --- a/courses/templates/partials/learning-techniques.html +++ b/courses/templates/partials/learning-techniques.html @@ -1,8 +1,8 @@ {% load wagtailimages_tags %}
-

How You’ll Learn

+

{{ page.title }}

    - {% for technique in course.techniques.technique_items %} + {% for technique in page.technique_items %}
  • {% image technique.value.image height-110 format-png %} diff --git a/courses/templates/partials/testimonial-carousel.html b/courses/templates/partials/testimonial-carousel.html index 923502f67..1d7c0c9aa 100644 --- a/courses/templates/partials/testimonial-carousel.html +++ b/courses/templates/partials/testimonial-carousel.html @@ -2,11 +2,11 @@
    -

    {{ testimonials.heading }}

    -

    {{ testimonials.subhead }}

    +

    {{ page.heading }}

    +

    {{ page.subhead }}

    - {% for testimonial in testimonials.items %} + {% for testimonial in page.items %}
    {% if testimonial.value.image %} @@ -20,7 +20,7 @@

    {{ testimonial.value.name }}, {{ testimonial.value.title }}

    {% endfor %}
    - {% for testimonial in testimonials.items %} + {% for testimonial in page.items %} @@ -302,15 +354,14 @@ const mapStateToProps = state => ({ }) const mapDispatchToProps = dispatch => ({ checkout: () => dispatch(mutateAsync(queries.ecommerce.checkoutMutation())), + fetchBasket: () => dispatch(requestAsync(queries.ecommerce.basketQuery())), updateBasket: payload => dispatch(mutateAsync(queries.ecommerce.basketMutation(payload))) }) -const mapPropsToConfigs = () => [queries.ecommerce.basketQuery()] export default compose( connect( mapStateToProps, mapDispatchToProps - ), - connectRequest(mapPropsToConfigs) + ) )(CheckoutPage) diff --git a/static/js/containers/pages/CheckoutPage_test.js b/static/js/containers/pages/CheckoutPage_test.js index 16c7549de..58eba7f6c 100644 --- a/static/js/containers/pages/CheckoutPage_test.js +++ b/static/js/containers/pages/CheckoutPage_test.js @@ -36,7 +36,9 @@ describe("CheckoutPage", () => { basket } }, - {} + { + location: {} + } ) }) @@ -102,6 +104,127 @@ describe("CheckoutPage", () => { assert.equal(inner.find("img").prop("alt"), basketItem.description) assert.equal(inner.find(".item-row .title").text(), basketItem.description) }) + ;[true, false].forEach(hasError => { + it(`updates the basket with a product id from the query parameter${ + hasError ? ", but an error is returned" : "" + }`, async () => { + const productId = 4567 + if (hasError) { + helper.handleRequestStub.withArgs("/api/basket/", "PATCH").returns({ + status: 400, + body: { + errors: "error" + } + }) + } + const { inner } = await renderPage( + {}, + { + location: { + search: `product=${productId}` + } + } + ) + + sinon.assert.calledWith( + helper.handleRequestStub, + "/api/basket/", + "PATCH", + { + body: { items: [{ id: productId }] }, + credentials: undefined, + headers: { + "X-CSRFTOKEN": null + } + } + ) + assert.equal(inner.state().errors, hasError ? "error" : null) + }) + }) + + describe("mount", () => { + const productError = "product error", + couponError = "coupon error", + couponCode = "codeforcoupon", + productId = 12345 + let couponPayload, productPayload + + beforeEach(() => { + couponPayload = { + body: { coupons: [{ code: couponCode }] }, + credentials: undefined, + headers: { + "X-CSRFTOKEN": null + } + } + productPayload = { + body: { items: [{ id: productId }] }, + credentials: undefined, + headers: { + "X-CSRFTOKEN": null + } + } + }) + ;[ + [true, false, couponError], + [true, true, null], + [false, false, productError], + [false, true, productError] + ].forEach(([hasValidProductId, hasValidCoupon, expError]) => { + it(`updates the basket with a ${ + hasValidProductId ? "" : "in" + }valid product id and a ${ + hasValidCoupon ? "" : "in" + }valid coupon code from the query parameter`, async () => { + if (!hasValidProductId) { + helper.handleRequestStub + .withArgs("/api/basket/", "PATCH", productPayload) + .returns({ + status: 400, + body: { + errors: "product error" + } + }) + } + if (!hasValidCoupon) { + helper.handleRequestStub + .withArgs("/api/basket/", "PATCH", couponPayload) + .returns({ + status: 400, + body: { + errors: "coupon error" + } + }) + } + const { inner } = await renderPage( + {}, + { + location: { + search: `product=${productId}&code=${couponCode}` + } + } + ) + // wait for componentDidMount to resolve + await Promise.resolve() + sinon.assert.calledWith( + helper.handleRequestStub, + "/api/basket/", + "PATCH", + productPayload + ) + assert.equal( + helper.handleRequestStub.calledWith( + "/api/basket/", + "PATCH", + couponPayload + ), + hasValidProductId + ) + + assert.equal(inner.state().errors, expError) + }) + }) + }) it("displays the coupon code", async () => { const { inner } = await renderPage() @@ -153,6 +276,46 @@ describe("CheckoutPage", () => { }) }) + it("submits the coupon code currently in the basket if there is none in the state", async () => { + const { inner } = await renderPage() + const code = basket.coupons[0].code + await inner.find(".apply-button").prop("onClick")({ + preventDefault: helper.sandbox.stub() + }) + sinon.assert.calledWith(helper.handleRequestStub, "/api/basket/", "PATCH", { + body: { coupons: [{ code: code }] }, + credentials: undefined, + headers: { + "X-CSRFTOKEN": null + } + }) + }) + + it("clears the state after submitting the basket", async () => { + const { inner } = await renderPage() + const couponCode = "coupon code" + inner.setState({ + errors: "errors", + selectedRuns: ["runs"], + couponCode: couponCode + }) + await inner.find(".apply-button").prop("onClick")({ + preventDefault: helper.sandbox.stub() + }) + sinon.assert.calledWith(helper.handleRequestStub, "/api/basket/", "PATCH", { + body: { coupons: [{ code: couponCode }] }, + credentials: undefined, + headers: { + "X-CSRFTOKEN": null + } + }) + assert.deepEqual(inner.state(), { + couponCode: null, + errors: null, + selectedRuns: null + }) + }) + it("tries to submit the coupon code but receives an error message", async () => { const { inner } = await renderPage() const errors = "Unknown error" @@ -162,15 +325,14 @@ describe("CheckoutPage", () => { errors } }) - await inner.find("form").prop("onSubmit")({ - preventDefault: helper.sandbox.stub() - }) + await assertRaises(async () => { + await inner.find("form").prop("onSubmit")({ + preventDefault: helper.sandbox.stub() + }) + }, "Received error from request") assert.equal(inner.state().errors, errors) - assert.equal( - inner.find(".enrollment-input .error").text(), - "Error: Unknown error" - ) + assert.equal(inner.find(".enrollment-input .error").text(), "Unknown error") assert.isTrue(inner.find(".enrollment-input input.error-border").exists()) }) diff --git a/static/js/containers/pages/register/RegisterDetailsPage.js b/static/js/containers/pages/register/RegisterDetailsPage.js index fe2ff64ef..c2a98bd5a 100644 --- a/static/js/containers/pages/register/RegisterDetailsPage.js +++ b/static/js/containers/pages/register/RegisterDetailsPage.js @@ -1,4 +1,5 @@ // @flow +/* global SETTINGS: false */ import React from "react" import { compose } from "redux" import { connect } from "react-redux" @@ -10,7 +11,11 @@ import qs from "query-string" import auth from "../../../lib/queries/auth" import users from "../../../lib/queries/users" import { routes } from "../../../lib/urls" -import { STATE_REGISTER_EXTRA_DETAILS } from "../../../lib/auth" +import { + STATE_REGISTER_EXTRA_DETAILS, + STATE_USER_BLOCKED, + STATE_ERROR_TEMPORARY +} from "../../../lib/auth" import queries from "../../../lib/queries" import { qsPartialTokenSelector } from "../../../lib/selectors" @@ -21,6 +26,7 @@ import type { Response } from "redux-query" import type { AuthResponse, AuthResponseRaw, + AuthStates, LegalAddress, User, Country @@ -36,6 +42,10 @@ type StateProps = {| countries: Array |} +type State = {| + authState: ?AuthStates +|} + type DispatchProps = {| registerDetails: ( name: string, @@ -52,7 +62,14 @@ type Props = {| ...DispatchProps |} -class RegisterProfilePage extends React.Component { +class RegisterProfilePage extends React.Component { + constructor(props) { + super(props) + this.state = { + authState: null + } + } + async onSubmit(detailsData, { setSubmitting, setErrors }) { const { history, @@ -75,6 +92,13 @@ class RegisterProfilePage extends React.Component { partial_token }) history.push(`${routes.register.extra}?${params}`) + } else if ( + state === STATE_USER_BLOCKED || + state === STATE_ERROR_TEMPORARY + ) { + this.setState({ + authState: state + }) } else if (errors.length > 0) { setErrors({ email: errors[0] @@ -85,8 +109,33 @@ class RegisterProfilePage extends React.Component { } } + renderAuthState(authState: AuthStates) { + switch (authState) { + case STATE_USER_BLOCKED: + return ( +
    + Sorry, we cannot create an account for you at this time. Please + contact us at{" "} + + {SETTINGS.support_email} + +
    + ) + case STATE_ERROR_TEMPORARY: + return ( +
    + Unable to complete registration at this time, please try again + later. +
    + ) + default: + return
    Unknown error, plase contact support
    + } + } + render() { const { countries } = this.props + const { authState } = this.state return (
    @@ -99,12 +148,16 @@ class RegisterProfilePage extends React.Component { Click here
    -
    - -
    + {authState ? ( + this.renderAuthState(authState) + ) : ( +
    + +
    + )}
    ) } diff --git a/static/js/flow/authTypes.js b/static/js/flow/authTypes.js index 93b73503b..344e2b424 100644 --- a/static/js/flow/authTypes.js +++ b/static/js/flow/authTypes.js @@ -4,7 +4,10 @@ export type AuthStates = | "success" | "inactive" + | "invalid-email" + | "user-blocked" | "error" + | "error-temporary" | "login/email" | "login/password" | "login/provider" diff --git a/static/js/flow/declarations.js b/static/js/flow/declarations.js index 399ffb7e8..b7b35e49a 100644 --- a/static/js/flow/declarations.js +++ b/static/js/flow/declarations.js @@ -8,7 +8,8 @@ declare type Settings = { release_version: string, environment: string, gaTrackingID: ?string, - recaptchaKey: ?string + recaptchaKey: ?string, + support_email: string, } declare var SETTINGS: Settings; diff --git a/static/js/lib/auth.js b/static/js/lib/auth.js index f5356053c..20b3d5bfb 100644 --- a/static/js/lib/auth.js +++ b/static/js/lib/auth.js @@ -8,9 +8,11 @@ export const FLOW_REGISTER = "register" export const FLOW_LOGIN = "login" export const STATE_ERROR = "error" +export const STATE_ERROR_TEMPORARY = "error-temporary" export const STATE_SUCCESS = "success" export const STATE_INACTIVE = "inactive" export const STATE_INVALID_EMAIL = "invalid-email" +export const STATE_USER_BLOCKED = "user-blocked" export const STATE_LOGIN_EMAIL = "login/email" export const STATE_LOGIN_PASSWORD = "login/password" diff --git a/static/js/lib/form.js b/static/js/lib/form.js index e37617bad..df8271422 100644 --- a/static/js/lib/form.js +++ b/static/js/lib/form.js @@ -1,5 +1,6 @@ // @flow import type { CheckoutPayload } from "../flow/ecommerceTypes" +import React from "react" /** * Creates a POST form with hidden input fields @@ -25,3 +26,23 @@ export function createCyberSourceForm( } return form } + +export const formatErrors = ( + errors: string | Object | null +): React$Element<*> | null => { + if (!errors) { + return null + } + + let errorString + if (typeof errors === "object") { + if (errors.items) { + errorString = errors.items[0] + } else { + errorString = errors[0] + } + } else { + errorString = errors + } + return
    {errorString}
    +} diff --git a/static/js/lib/form_test.js b/static/js/lib/form_test.js index 0b2551113..a68ff7ff2 100644 --- a/static/js/lib/form_test.js +++ b/static/js/lib/form_test.js @@ -1,8 +1,9 @@ // @flow import { assert } from "chai" +import { shallow } from "enzyme" import { CYBERSOURCE_CHECKOUT_RESPONSE } from "./test_constants" -import { createCyberSourceForm } from "./form" +import { createCyberSourceForm, formatErrors } from "./form" describe("form functions", () => { it("creates a form with hidden values corresponding to the payload", () => { @@ -23,4 +24,27 @@ describe("form functions", () => { assert.equal(form.getAttribute("action"), url) assert.equal(form.getAttribute("method"), "post") }) + + describe("formatErrors", () => { + it("should return null if there is no error", () => { + assert.isNull(formatErrors(null)) + }) + + it("should return a div with the error string if the error is a string", () => { + const wrapper = shallow(formatErrors("error")) + assert.equal(wrapper.find(".error").text(), "error") + }) + + it("should return the first item in the items in the error object if it has items", () => { + const error = { items: ["error"] } + const wrapper = shallow(formatErrors(error)) + assert.equal(wrapper.find(".error").text(), "error") + }) + + it("should return the first item in the error if there is no 'items'", () => { + const error = ["error"] + const wrapper = shallow(formatErrors(error)) + assert.equal(wrapper.find(".error").text(), "error") + }) + }) }) diff --git a/static/js/notifications.js b/static/js/notifications.js new file mode 100644 index 000000000..59cdffcdd --- /dev/null +++ b/static/js/notifications.js @@ -0,0 +1,25 @@ +/*eslint-env jquery*/ +/*eslint semi: ["error", "always"]*/ + +$(document).ready(function() { + renderSiteNotification(); + + $(".notifications").on("click", ".close-notification", function(e) { + e.preventDefault(); + const $notification = $(this).closest(".notification"); + localStorage.setItem( + "dismissedNotification", + $notification.data("notification-id") + ); + $notification.remove(); + }); +}); + +function renderSiteNotification() { + const notificationId = $(".notification") + .data("notification-id") + .toString(); + if (localStorage.getItem("dismissedNotification") !== notificationId) { + $(".notifications").removeClass("d-none"); + } +} diff --git a/static/scss/layout.scss b/static/scss/layout.scss index c11e07884..41f071d19 100644 --- a/static/scss/layout.scss +++ b/static/scss/layout.scss @@ -30,6 +30,7 @@ @import "detail/subnav"; @import "ecommerce-admin"; @import "resource"; +@import "notification"; body { font-family: Rajdhani,"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif; diff --git a/static/scss/notification.scss b/static/scss/notification.scss new file mode 100644 index 000000000..311932f48 --- /dev/null +++ b/static/scss/notification.scss @@ -0,0 +1,18 @@ +.notifications { + background: $primary; + text-align: center; + color: white; + + .close-notification { + text-decoration: none; + color: white; + float: right; + margin-right: 10px; + + &:after { + content: "\e906"; + font-family: icomoon; + margin-left: 10px; + } + } +} diff --git a/test_requirements.txt b/test_requirements.txt index fde7f3ff5..19910eec3 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -10,6 +10,7 @@ ipdb nplusone>=0.8.1 pdbpp pip-tools +hypothesis==4.23.4 pylint==2.1.1 pylint-django==2.0.2 pytest==4.4.1 diff --git a/users/factories.py b/users/factories.py index 8619ab816..4311fe04d 100644 --- a/users/factories.py +++ b/users/factories.py @@ -15,6 +15,8 @@ class UserFactory(DjangoModelFactory): email = FuzzyText(suffix="@example.com") name = Faker("name") + is_active = True + legal_address = RelatedFactory("users.factories.LegalAddressFactory", "user") class Meta: diff --git a/users/migrations/0006_user_is_active_false.py b/users/migrations/0006_user_is_active_false.py new file mode 100644 index 000000000..79d778837 --- /dev/null +++ b/users/migrations/0006_user_is_active_false.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.7 on 2019-05-15 20:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("users", "0005_profile")] + + operations = [ + migrations.AlterField( + model_name="user", + name="is_active", + field=models.BooleanField( + default=False, help_text="The user account is active" + ), + ) + ] diff --git a/users/models.py b/users/models.py index 474b446cb..5a89e63b6 100644 --- a/users/models.py +++ b/users/models.py @@ -79,6 +79,7 @@ def create_superuser(self, username, email, password, **extra_fields): """Create a superuser""" extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_superuser", True) + extra_fields.setdefault("is_active", True) if extra_fields.get("is_staff") is not True: raise ValueError("Superuser must have is_staff=True.") @@ -107,7 +108,7 @@ class User(AbstractBaseUser, TimestampedModel, PermissionsMixin): default=False, help_text="The user can access the admin site" ) is_active = models.BooleanField( - default=True, help_text="The user account is active" + default=False, help_text="The user account is active" ) objects = UserManager() diff --git a/users/models_test.py b/users/models_test.py index 4018865e6..9edcd394d 100644 --- a/users/models_test.py +++ b/users/models_test.py @@ -9,10 +9,10 @@ @pytest.mark.parametrize( - "create_func,exp_staff,exp_superuser", + "create_func,exp_staff,exp_superuser,exp_is_active", [ - [User.objects.create_user, False, False], - [User.objects.create_superuser, True, True], + [User.objects.create_user, False, False, False], + [User.objects.create_superuser, True, True, True], ], ) @pytest.mark.parametrize("username", [None, "user1"]) @@ -21,10 +21,11 @@ def test_create_user( create_func, exp_staff, exp_superuser, + exp_is_active, username, password, patch_create_edx_user_task, -): +): # pylint: disable=too-many-arguments """Test creating a user""" email = "uSer@EXAMPLE.com" name = "Jane Doe" @@ -39,7 +40,7 @@ def test_create_user( assert user.get_full_name() == name assert user.is_staff is exp_staff assert user.is_superuser is exp_superuser - assert user.is_active is True + assert user.is_active is exp_is_active if password is not None: assert user.check_password(password) patch_create_edx_user_task.delay.assert_called_once_with(user.id) diff --git a/users/serializers.py b/users/serializers.py index 3fbecc9a9..e7db6bada 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -127,6 +127,29 @@ class Meta: } +class ProfileSerializer(serializers.ModelSerializer): + """Serializer for Profile """ + + class Meta: + model = Profile + fields = ( + "id", + "user", + "birth_year", + "gender", + "company", + "company_size", + "industry", + "job_title", + "job_function", + "years_experience", + "leadership_level", + "created_on", + "updated_on", + ) + read_only_fields = ("created_on", "updated_on") + + class PublicUserSerializer(serializers.ModelSerializer): """Serializer for public user data""" @@ -143,6 +166,7 @@ class UserSerializer(serializers.ModelSerializer): email = WriteableSerializerMethodField() username = WriteableSerializerMethodField() legal_address = LegalAddressSerializer(allow_null=True) + profile = ProfileSerializer(allow_null=True, required=False) def validate_email(self, value): """Empty validation function, but this is required for WriteableSerializerMethodField""" @@ -164,6 +188,7 @@ def get_username(self, instance): def create(self, validated_data): """Create a new user""" legal_address_data = validated_data.pop("legal_address") + profile_data = validated_data.pop("profile", None) username = validated_data.pop("username") email = validated_data.pop("email") @@ -173,7 +198,7 @@ def create(self, validated_data): username, email=email, password=password, **validated_data ) - # this side-effects such that user.legal_address is updated in-place + # this side-effects such that user.legal_address and user.profile are updated in-place if legal_address_data: legal_address = LegalAddressSerializer( user.legal_address, data=legal_address_data @@ -181,22 +206,35 @@ def create(self, validated_data): if legal_address.is_valid(): legal_address.save() + if profile_data: + profile = ProfileSerializer(user.profile, data=profile_data) + if profile.is_valid(): + profile.save() + return user @transaction.atomic def update(self, instance, validated_data): """Update an existing user""" legal_address_data = validated_data.pop("legal_address", None) + profile_data = validated_data.pop("profile", None) password = validated_data.pop("password", None) + # this side-effects such that user.legal_address and user.profile are updated in-place if legal_address_data: - # this side-effects such that instance.legal_address is updated in-place address_serializer = LegalAddressSerializer( instance.legal_address, data=legal_address_data ) if address_serializer.is_valid(raise_exception=True): address_serializer.save() + if profile_data: + profile_serializer = LegalAddressSerializer( + instance.profile, data=profile_data + ) + if profile_serializer.is_valid(raise_exception=True): + profile_serializer.save() + # save() will be called in super().update() if password is not None: instance.set_password(password) @@ -212,6 +250,7 @@ class Meta: "email", "password", "legal_address", + "profile", "is_anonymous", "is_authenticated", "created_on", @@ -226,29 +265,6 @@ class Meta: ) -class ProfileSerializer(serializers.ModelSerializer): - """Serializer for Profile """ - - class Meta: - model = Profile - fields = ( - "id", - "user", - "birth_year", - "gender", - "company", - "company_size", - "industry", - "job_title", - "job_function", - "years_experience", - "leadership_level", - "created_on", - "updated_on", - ) - read_only_fields = ("created_on", "updated_on") - - class StateProvinceSerializer(serializers.Serializer): """ Serializer for pycountry states/provinces""" diff --git a/users/views_test.py b/users/views_test.py index 614b52ed3..d5168abdd 100644 --- a/users/views_test.py +++ b/users/views_test.py @@ -55,6 +55,7 @@ def test_get_user_by_me(client, user, is_anonymous): "legal_address": None, "is_anonymous": True, "is_authenticated": False, + "profile": None, } if is_anonymous else {