Implemented fake payment page.
This commit is contained in:
229
lms/djangoapps/shoppingcart/tests/payment_fake.py
Normal file
229
lms/djangoapps/shoppingcart/tests/payment_fake.py
Normal file
@@ -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)
|
||||
112
lms/djangoapps/shoppingcart/tests/test_payment_fake.py
Normal file
112
lms/djangoapps/shoppingcart/tests/test_payment_fake.py
Normal file
@@ -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')
|
||||
@@ -13,3 +13,10 @@ if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']:
|
||||
url(r'^remove_item/$', 'remove_item'),
|
||||
url(r'^add/course/(?P<course_id>[^/]+/[^/]+/[^/]+)/$', '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())
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
9
lms/templates/shoppingcart/test/fake_payment_error.html
Normal file
9
lms/templates/shoppingcart/test/fake_payment_error.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Payment Error</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>An error occurred while you submitted your order.
|
||||
If you are trying to make a purchase, please contact the merchant.</p>
|
||||
</body>
|
||||
</html>
|
||||
12
lms/templates/shoppingcart/test/fake_payment_page.html
Normal file
12
lms/templates/shoppingcart/test/fake_payment_page.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<html>
|
||||
<head><title>Payment Form</title></head>
|
||||
<body>
|
||||
<p>Payment page</p>
|
||||
<form name="input" action="${callback_url}" method="post">
|
||||
% for name, value in post_params.items():
|
||||
<input type="hidden" name="${name}" value="${value}">
|
||||
% endfor
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user