diff --git a/lms/djangoapps/shoppingcart/tests/payment_fake.py b/lms/djangoapps/shoppingcart/tests/payment_fake.py new file mode 100644 index 0000000000..ab0d16fa51 --- /dev/null +++ b/lms/djangoapps/shoppingcart/tests/payment_fake.py @@ -0,0 +1,229 @@ +""" +Fake payment page for use in acceptance tests. +This view is enabled in the URLs by the feature flag `ENABLE_PAYMENT_FAKE`. + +Note that you will still need to configure this view as the payment +processor endpoint in order for the shopping cart to use it: + + settings.CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = "/shoppingcart/payment_fake" + +You can configure the payment to indicate success or failure by sending a PUT +request to the view with param "success" +set to "success" or "failure". The view defaults to payment success. +""" + +from django.views.generic.base import View +from django.views.decorators.csrf import csrf_exempt +from django.http import HttpResponse, HttpResponseBadRequest +from mitxmako.shortcuts import render_to_response + + +# We use the same hashing function as the software under test, +# because it mainly uses standard libraries, and I want +# to avoid duplicating that code. +from shoppingcart.processors.CyberSource import processor_hash + + +class PaymentFakeView(View): + """ + Fake payment page for use in acceptance tests. + """ + + # We store the payment status to respond with in a class + # variable. In a multi-process Django app, this wouldn't work, + # since processes don't share memory. Since Lettuce + # runs one Django server process, this works for acceptance testing. + PAYMENT_STATUS_RESPONSE = "success" + + @csrf_exempt + def dispatch(self, *args, **kwargs): + """ + Disable CSRF for these methods. + """ + return super(PaymentFakeView, self).dispatch(*args, **kwargs) + + def post(self, request): + """ + Render a fake payment page. + + This is an HTML form that: + + * Triggers a POST to `postpay_callback()` on submit. + + * Has hidden fields for all the data CyberSource sends to the callback. + - Most of this data is duplicated from the request POST params (e.g. `amount` and `course_id`) + - Other params contain fake data (always the same user name and address. + - Still other params are calculated (signatures) + + * Serves an error page (HTML) with a 200 status code + if the signatures are invalid. This is what CyberSource does. + + Since all the POST requests are triggered by HTML forms, this is + equivalent to the CyberSource payment page, even though it's + served by the shopping cart app. + """ + if self._is_signature_valid(request.POST): + return self._payment_page_response(request.POST, '/postpay_callback') + + else: + return render_to_response('shoppingcart/test/fake_payment_error.html') + + def put(self, request): + """ + Set the status of payment requests to success or failure. + + Accepts one POST param "status" that can be either "success" + or "failure". + """ + new_status = request.body + + if not new_status in ["success", "failure"]: + return HttpResponseBadRequest() + + else: + # Configure all views to respond with the new status + PaymentFakeView.PAYMENT_STATUS_RESPONSE = new_status + return HttpResponse() + + @staticmethod + def _is_signature_valid(post_params): + """ + Return a bool indicating whether the client sent + us a valid signature in the payment page request. + """ + + # Calculate the fields signature + fields_sig = processor_hash(post_params.get('orderPage_signedFields')) + + # Retrieve the list of signed fields + signed_fields = post_params.get('orderPage_signedFields').split(',') + + # Calculate the public signature + hash_val = ",".join([ + "{0}={1}".format(key, post_params[key]) + for key in signed_fields + ]) + ",signedFieldsPublicSignature={0}".format(fields_sig) + + public_sig = processor_hash(hash_val) + + return public_sig == post_params.get('orderPage_signaturePublic') + + @classmethod + def response_post_params(cls, post_params): + """ + Calculate the POST params we want to send back to the client. + """ + resp_params = { + # Indicate whether the payment was successful + "decision": "ACCEPT" if cls.PAYMENT_STATUS_RESPONSE == "success" else "REJECT", + + # Reflect back whatever the client sent us, + # defaulting to `None` if a paramter wasn't received + "course_id": post_params.get('course_id'), + "orderAmount": post_params.get('amount'), + "ccAuthReply_amount": post_params.get('amount'), + "orderPage_transactionType": post_params.get('orderPage_transactionType'), + "orderPage_serialNumber": post_params.get('orderPage_serialNumber'), + "orderNumber": post_params.get('orderNumber'), + "orderCurrency": post_params.get('currency'), + "match": post_params.get('match'), + "merchantID": post_params.get('merchantID'), + + # Send fake user data + "billTo_firstName": "John", + "billTo_lastName": "Doe", + "billTo_street1": "123 Fake Street", + "billTo_state": "MA", + "billTo_city": "Boston", + "billTo_postalCode": "02134", + "billTo_country": "us", + + # Send fake data for other fields + "card_cardType": "001", + "card_accountNumber": "############1111", + "card_expirationMonth": "08", + "card_expirationYear": "2019", + "paymentOption": "card", + "orderPage_environment": "TEST", + "orderPage_requestToken": "unused", + "reconciliationID": "39093601YKVO1I5D", + "ccAuthReply_authorizationCode": "888888", + "ccAuthReply_avsCodeRaw": "I1", + "reasonCode": "100", + "requestID": "3777139938170178147615", + "ccAuthReply_reasonCode": "100", + "ccAuthReply_authorizedDateTime": "2013-08-28T181954Z", + "ccAuthReply_processorResponse": "100", + "ccAuthReply_avsCode": "X", + + # We don't use these signatures + "transactionSignature": "unused=", + "decision_publicSignature": "unused=", + "orderAmount_publicSignature": "unused=", + "orderNumber_publicSignature": "unused=", + "orderCurrency_publicSignature": "unused=", + } + + # Indicate which fields we are including in the signature + # Order is important + signed_fields = [ + 'billTo_lastName', 'orderAmount', 'course_id', + 'billTo_street1', 'card_accountNumber', 'orderAmount_publicSignature', + 'orderPage_serialNumber', 'orderCurrency', 'reconciliationID', + 'decision', 'ccAuthReply_processorResponse', 'billTo_state', + 'billTo_firstName', 'card_expirationYear', 'billTo_city', + 'billTo_postalCode', 'orderPage_requestToken', 'ccAuthReply_amount', + 'orderCurrency_publicSignature', 'orderPage_transactionType', + 'ccAuthReply_authorizationCode', 'decision_publicSignature', + 'match', 'ccAuthReply_avsCodeRaw', 'paymentOption', + 'billTo_country', 'reasonCode', 'ccAuthReply_reasonCode', + 'orderPage_environment', 'card_expirationMonth', 'merchantID', + 'orderNumber_publicSignature', 'requestID', 'orderNumber', + 'ccAuthReply_authorizedDateTime', 'card_cardType', 'ccAuthReply_avsCode' + ] + + # Add the list of signed fields + resp_params['signedFields'] = ",".join(signed_fields) + + # Calculate the fields signature + signed_fields_sig = processor_hash(resp_params['signedFields']) + + # Calculate the public signature + hash_val = ",".join([ + "{0}={1}".format(key, resp_params[key]) + for key in signed_fields + ]) + ",signedFieldsPublicSignature={0}".format(signed_fields_sig) + + resp_params['signedDataPublicSignature'] = processor_hash(hash_val) + + return resp_params + + def _payment_page_response(self, post_params, callback_url): + """ + Render the payment page to a response. This is an HTML form + that triggers a POST request to `callback_url`. + + The POST params are described in the CyberSource documentation: + http://apps.cybersource.com/library/documentation/dev_guides/HOP_UG/html/wwhelp/wwhimpl/js/html/wwhelp.htm + + To figure out the POST params to send to the callback, + we either: + + 1) Use fake static data (e.g. always send user name "John Doe") + 2) Use the same info we received (e.g. send the same `course_id` and `amount`) + 3) Dynamically calculate signatures using a shared secret + """ + + # Build the context dict used to render the HTML form, + # filling in values for the hidden input fields. + # These will be sent in the POST request to the callback URL. + context_dict = { + + # URL to send the POST request to + "callback_url": callback_url, + + # POST params embedded in the HTML form + 'post_params': self.response_post_params(post_params) + } + + return render_to_response('shoppingcart/test/fake_payment_page.html', context_dict) diff --git a/lms/djangoapps/shoppingcart/tests/test_payment_fake.py b/lms/djangoapps/shoppingcart/tests/test_payment_fake.py new file mode 100644 index 0000000000..d59767908f --- /dev/null +++ b/lms/djangoapps/shoppingcart/tests/test_payment_fake.py @@ -0,0 +1,112 @@ +""" +Tests for the fake payment page used in acceptance tests. +""" + +from django.test import TestCase +from shoppingcart.processors.CyberSource import sign, verify_signatures, \ + CCProcessorSignatureException +from shoppingcart.tests.payment_fake import PaymentFakeView +from collections import OrderedDict + + +class PaymentFakeViewTest(TestCase): + """ + Test that the fake payment view interacts + correctly with the shopping cart. + """ + + CLIENT_POST_PARAMS = OrderedDict([ + ('match', 'on'), + ('course_id', 'edx/999/2013_Spring'), + ('amount', '25.00'), + ('currency', 'usd'), + ('orderPage_transactionType', 'sale'), + ('orderNumber', '33'), + ('merchantID', 'edx'), + ('djch', '012345678912'), + ('orderPage_version', 2), + ('orderPage_serialNumber', '1234567890'), + ]) + + def setUp(self): + super(PaymentFakeViewTest, self).setUp() + + # Reset the view state + PaymentFakeView.PAYMENT_STATUS_RESPONSE = "success" + + def test_accepts_client_signatures(self): + + # Generate shoppingcart signatures + post_params = sign(self.CLIENT_POST_PARAMS) + + # Simulate a POST request from the payment workflow + # page to the fake payment page. + resp = self.client.post( + '/shoppingcart/payment_fake', dict(post_params) + ) + + # Expect that the response was successful + self.assertEqual(resp.status_code, 200) + + # Expect that we were served the payment page + # (not the error page) + self.assertIn("Payment Form", resp.content) + + def test_rejects_invalid_signature(self): + + # Generate shoppingcart signatures + post_params = sign(self.CLIENT_POST_PARAMS) + + # Tamper with the signature + post_params['orderPage_signaturePublic'] = "invalid" + + # Simulate a POST request from the payment workflow + # page to the fake payment page. + resp = self.client.post( + '/shoppingcart/payment_fake', dict(post_params) + ) + + # Expect that we got an error + self.assertIn("Error", resp.content) + + def test_sends_valid_signature(self): + + # Generate shoppingcart signatures + post_params = sign(self.CLIENT_POST_PARAMS) + + # Get the POST params that the view would send back to us + resp_params = PaymentFakeView.response_post_params(post_params) + + # Check that the client accepts these + try: + verify_signatures(resp_params) + + except CCProcessorSignatureException: + self.fail("Client rejected signatures.") + + def test_set_payment_status(self): + + # Generate shoppingcart signatures + post_params = sign(self.CLIENT_POST_PARAMS) + + # Configure the view to fail payments + resp = self.client.put( + '/shoppingcart/payment_fake', + data="failure", content_type='text/plain' + ) + self.assertEqual(resp.status_code, 200) + + # Check that the decision is "REJECT" + resp_params = PaymentFakeView.response_post_params(post_params) + self.assertEqual(resp_params.get('decision'), 'REJECT') + + # Configure the view to accept payments + resp = self.client.put( + '/shoppingcart/payment_fake', + data="success", content_type='text/plain' + ) + self.assertEqual(resp.status_code, 200) + + # Check that the decision is "ACCEPT" + resp_params = PaymentFakeView.response_post_params(post_params) + self.assertEqual(resp_params.get('decision'), 'ACCEPT') diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 800c6077aa..9522d15298 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -13,3 +13,10 @@ if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']: url(r'^remove_item/$', 'remove_item'), url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart', name='add_course_to_cart'), ) + +if settings.MITX_FEATURES.get('ENABLE_PAYMENT_FAKE'): + from shoppingcart.tests.payment_fake import PaymentFakeView + urlpatterns += patterns( + 'shoppingcart.tests.payment_fake', + url(r'^payment_fake', PaymentFakeView.as_view()) + ) diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index cf64404161..34112566ee 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -19,6 +19,7 @@ import logging logging.disable(logging.ERROR) import os from random import choice, randint +import string def seed(): @@ -83,6 +84,23 @@ MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True # Use the auto_auth workflow for creating users and logging them in MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True +# Enable fake payment processing page +MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True + +# Configure the payment processor to use the fake processing page +# Since both the fake payment page and the shoppingcart app are using +# the same settings, we can generate this randomly and guarantee +# that they are using the same secret. +RANDOM_SHARED_SECRET = ''.join( + choice(string.letters + string.digits + string.punctuation) + for x in range(250) +) + +CC_PROCESSOR['CyberSource']['SHARED_SECRET'] = RANDOM_SHARED_SECRET +CC_PROCESSOR['CyberSource']['MERCHANT_ID'] = "edx" +CC_PROCESSOR['CyberSource']['SERIAL_NUMBER'] = "0123456789012345678901" +CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = "/shoppingcart/payment_fake" + # HACK # Setting this flag to false causes imports to not load correctly in the lettuce python files # We do not yet understand why this occurs. Setting this to true is a stopgap measure diff --git a/lms/envs/test.py b/lms/envs/test.py index a9c51310f6..94feffdf3e 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -155,6 +155,26 @@ OPENID_UPDATE_DETAILS_FROM_SREG = True OPENID_USE_AS_ADMIN_LOGIN = False OPENID_PROVIDER_TRUSTED_ROOTS = ['*'] +###################### Payment ##############################3 +# Enable fake payment processing page +MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True + +# Configure the payment processor to use the fake processing page +# Since both the fake payment page and the shoppingcart app are using +# the same settings, we can generate this randomly and guarantee +# that they are using the same secret. +from random import choice +import string +RANDOM_SHARED_SECRET = ''.join( + choice(string.letters + string.digits + string.punctuation) + for x in range(250) +) + +CC_PROCESSOR['CyberSource']['SHARED_SECRET'] = RANDOM_SHARED_SECRET +CC_PROCESSOR['CyberSource']['MERCHANT_ID'] = "edx" +CC_PROCESSOR['CyberSource']['SERIAL_NUMBER'] = "0123456789012345678901" +CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = "/shoppingcart/payment_fake" + ################################# CELERY ###################################### CELERY_ALWAYS_EAGER = True diff --git a/lms/templates/shoppingcart/test/fake_payment_error.html b/lms/templates/shoppingcart/test/fake_payment_error.html new file mode 100644 index 0000000000..fcfe21ed15 --- /dev/null +++ b/lms/templates/shoppingcart/test/fake_payment_error.html @@ -0,0 +1,9 @@ + + + Payment Error + + +

An error occurred while you submitted your order. + If you are trying to make a purchase, please contact the merchant.

+ + diff --git a/lms/templates/shoppingcart/test/fake_payment_page.html b/lms/templates/shoppingcart/test/fake_payment_page.html new file mode 100644 index 0000000000..ba488bbdb4 --- /dev/null +++ b/lms/templates/shoppingcart/test/fake_payment_page.html @@ -0,0 +1,12 @@ + +Payment Form + +

Payment page

+
+ % for name, value in post_params.items(): + + % endfor + +
+ +