Commit e0372b00 by Will Daly Committed by Jay Zoldak

Implemented fake payment page.

parent a52ca363
"""
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)
"""
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
......
<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>
<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>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment