diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 20b2b1bda8..740908624c 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -45,7 +45,7 @@ def process_postpay_callback(params): except CCProcessorException as e: return {'success': False, 'order': None, #due to exception we may not have the order - 'error_html': get_processor_exception_html(params, e)} + 'error_html': get_processor_exception_html(e)} def hash(value): @@ -57,7 +57,7 @@ def hash(value): return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want -def sign(params): +def sign(params, signed_fields_key='orderPage_signedFields', full_sig_key='orderPage_signaturePublic'): """ params needs to be an ordered dict, b/c cybersource documentation states that order is important. Reverse engineered from PHP version provided by cybersource @@ -74,8 +74,8 @@ def sign(params): values = ",".join(["{0}={1}".format(i,params[i]) for i in params.keys()]) fields_sig = hash(fields) values += ",signedFieldsPublicSignature=" + fields_sig - params['orderPage_signaturePublic'] = hash(values) - params['orderPage_signedFields'] = fields + params[full_sig_key] = hash(values) + params[signed_fields_key] = fields return params @@ -97,7 +97,7 @@ def verify_signatures(params, signed_fields_key='signedFields', full_sig_key='si raise CCProcessorSignatureException() -def render_purchase_form_html(cart, user): +def render_purchase_form_html(cart): """ Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource """ @@ -111,14 +111,6 @@ def render_purchase_form_html(cart, user): params['currency'] = cart.currency params['orderPage_transactionType'] = 'sale' params['orderNumber'] = "{0:d}".format(cart.id) - idx=1 - for item in cart_items: - prefix = "item_{0:d}_".format(idx) - params[prefix+'productSKU'] = "{0:d}".format(item.id) - params[prefix+'quantity'] = item.qty - params[prefix+'productName'] = item.line_desc - params[prefix+'unitPrice'] = item.unit_cost - params[prefix+'taxAmount'] = "0.00" signed_param_dict = sign(params) return render_to_string('shoppingcart/cybersource_form.html', { @@ -179,14 +171,14 @@ def payment_accepted(params): else: raise CCProcessorWrongAmountException( _("The amount charged by the processor {0} {1} is different than the total cost of the order {2} {3}."\ - .format(valid_params['ccAuthReply_amount'], valid_params['orderCurrency'], + .format(charged_amt, valid_params['orderCurrency'], order.total_cost, order.currency)) ) else: return {'accepted': False, 'amt_charged': 0, 'currency': 'usd', - 'order': None} + 'order': order} def record_purchase(params, order): @@ -236,7 +228,7 @@ def get_processor_decline_html(params): email=payment_support_email) -def get_processor_exception_html(params, exception): +def get_processor_exception_html(exception): """Return error HTML associated with exception""" payment_support_email = settings.PAYMENT_SUPPORT_EMAIL @@ -267,10 +259,11 @@ def get_processor_exception_html(params, exception):
Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are unable to validate that the message actually came from the payment processor. + The specific error message is: {msg}. We apologize that we cannot verify whether the charge went through and take further action on your order. Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}.
- """.format(email=payment_support_email))) + """.format(msg=exception.message, email=payment_support_email))) return msg # fallthrough case, which basically never happens diff --git a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py index 0dc3887437..df719d33b3 100644 --- a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py @@ -5,8 +5,12 @@ from collections import OrderedDict from django.test import TestCase from django.test.utils import override_settings from django.conf import settings +from student.tests.factories import UserFactory +from shoppingcart.models import Order, OrderItem from shoppingcart.processors.CyberSource import * -from shoppingcart.processors.exceptions import CCProcessorSignatureException +from shoppingcart.processors.exceptions import * +from mock import patch, Mock + TEST_CC_PROCESSOR = { 'CyberSource' : { @@ -25,8 +29,8 @@ class CyberSourceTests(TestCase): pass def test_override_settings(self): - self.assertEquals(settings.CC_PROCESSOR['CyberSource']['MERCHANT_ID'], 'edx_test') - self.assertEquals(settings.CC_PROCESSOR['CyberSource']['SHARED_SECRET'], 'secret') + self.assertEqual(settings.CC_PROCESSOR['CyberSource']['MERCHANT_ID'], 'edx_test') + self.assertEqual(settings.CC_PROCESSOR['CyberSource']['SHARED_SECRET'], 'secret') def test_hash(self): """ @@ -66,4 +70,218 @@ class CyberSourceTests(TestCase): with self.assertRaises(CCProcessorSignatureException): verify_signatures(params) + def test_get_processor_decline_html(self): + """ + Tests the processor decline html message + """ + DECISION = 'REJECT' + for code, reason in REASONCODE_MAP.iteritems(): + params={ + 'decision': DECISION, + 'reasonCode': code, + } + html = get_processor_decline_html(params) + self.assertIn(DECISION, html) + self.assertIn(reason, html) + self.assertIn(code, html) + self.assertIn(settings.PAYMENT_SUPPORT_EMAIL, html) + def test_get_processor_exception_html(self): + """ + Tests the processor exception html message + """ + for type in [CCProcessorSignatureException, CCProcessorWrongAmountException, CCProcessorDataException]: + error_msg = "An exception message of with exception type {0}".format(str(type)) + exception = type(error_msg) + html = get_processor_exception_html(exception) + self.assertIn(settings.PAYMENT_SUPPORT_EMAIL, html) + self.assertIn('Sorry!', html) + self.assertIn(error_msg, html) + + # test base case + self.assertIn("EXCEPTION!", get_processor_exception_html(CCProcessorException())) + + def test_record_purchase(self): + """ + Tests record_purchase with good and without returned CCNum + """ + student1 = UserFactory() + student1.save() + student2 = UserFactory() + student2.save() + params_cc = {'card_accountNumber':'1234', 'card_cardType':'001', 'billTo_firstName':student1.first_name} + params_nocc = {'card_accountNumber':'', 'card_cardType':'002', 'billTo_firstName':student2.first_name} + order1 = Order.get_cart_for_user(student1) + order2 = Order.get_cart_for_user(student2) + record_purchase(params_cc, order1) + record_purchase(params_nocc, order2) + self.assertEqual(order1.bill_to_ccnum, '1234') + self.assertEqual(order1.bill_to_cardtype, 'Visa') + self.assertEqual(order1.bill_to_first, student1.first_name) + self.assertEqual(order1.status, 'purchased') + + order2 = Order.objects.get(user=student2) + self.assertEqual(order2.bill_to_ccnum, '####') + self.assertEqual(order2.bill_to_cardtype, 'MasterCard') + self.assertEqual(order2.bill_to_first, student2.first_name) + self.assertEqual(order2.status, 'purchased') + + def test_payment_accepted_invalid_dict(self): + """ + Tests exception is thrown when params to payment_accepted don't have required key + or have an bad value + """ + baseline = { + 'orderNumber': '1', + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + } + wrong = { + 'orderNumber': 'k', + } + # tests for missing key + for key in baseline: + params = baseline.copy() + del params[key] + with self.assertRaises(CCProcessorDataException): + payment_accepted(params) + + # tests for keys with value that can't be converted to proper type + for key in wrong: + params = baseline.copy() + params[key] = wrong[key] + with self.assertRaises(CCProcessorDataException): + payment_accepted(params) + + def test_payment_accepted_order(self): + """ + Tests payment_accepted cases with an order + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + params = { + 'card_accountNumber': '1234', + 'card_cardType': '001', + 'billTo_firstName': student1.first_name, + 'orderNumber': str(order1.id), + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + 'ccAuthReply_amount': '0.00' + } + + # tests for an order number that doesn't match up + params_bad_ordernum = params.copy() + params_bad_ordernum['orderNumber'] = str(order1.id+10) + with self.assertRaises(CCProcessorDataException): + payment_accepted(params_bad_ordernum) + + # tests for a reply amount of the wrong type + params_wrong_type_amt = params.copy() + params_wrong_type_amt['ccAuthReply_amount'] = 'ab' + with self.assertRaises(CCProcessorDataException): + payment_accepted(params_wrong_type_amt) + + # tests for a reply amount of the wrong type + params_wrong_amt = params.copy() + params_wrong_amt['ccAuthReply_amount'] = '1.00' + with self.assertRaises(CCProcessorWrongAmountException): + payment_accepted(params_wrong_amt) + + # tests for a not accepted order + params_not_accepted = params.copy() + params_not_accepted['decision'] = "REJECT" + self.assertFalse(payment_accepted(params_not_accepted)['accepted']) + + # finally, tests an accepted order + self.assertTrue(payment_accepted(params)['accepted']) + + @patch('shoppingcart.processors.CyberSource.render_to_string', autospec=True) + def test_render_purchase_form_html(self, render): + """ + Tests the rendering of the purchase form + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + item1 = OrderItem(order=order1, user=student1, unit_cost=1.0, line_cost=1.0) + item1.save() + html = render_purchase_form_html(order1) + ((template, context), render_kwargs) = render.call_args + + self.assertEqual(template, 'shoppingcart/cybersource_form.html') + self.assertDictContainsSubset({'amount': '1.00', + 'currency': 'usd', + 'orderPage_transactionType': 'sale', + 'orderNumber':str(order1.id)}, + context['params']) + + def test_process_postpay_exception(self): + """ + Tests the exception path of process_postpay_callback + """ + baseline = { + 'orderNumber': '1', + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + } + # tests for missing key + for key in baseline: + params = baseline.copy() + del params[key] + result = process_postpay_callback(params) + self.assertFalse(result['success']) + self.assertIsNone(result['order']) + self.assertIn('error_msg', result['error_html']) + + @patch('shoppingcart.processors.CyberSource.verify_signatures', Mock(return_value=True)) + def test_process_postpay_accepted(self): + """ + Tests the ACCEPTED path of process_postpay + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + params = { + 'card_accountNumber': '1234', + 'card_cardType': '001', + 'billTo_firstName': student1.first_name, + 'orderNumber': str(order1.id), + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + 'ccAuthReply_amount': '0.00' + } + result = process_postpay_callback(params) + self.assertTrue(result['success']) + self.assertEqual(result['order'], order1) + order1 = Order.objects.get(id=order1.id) # reload from DB to capture side-effect of process_postpay_callback + self.assertEqual(order1.status, 'purchased') + self.assertFalse(result['error_html']) + + @patch('shoppingcart.processors.CyberSource.verify_signatures', Mock(return_value=True)) + def test_process_postpay_not_accepted(self): + """ + Tests the non-ACCEPTED path of process_postpay + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + params = { + 'card_accountNumber': '1234', + 'card_cardType': '001', + 'billTo_firstName': student1.first_name, + 'orderNumber': str(order1.id), + 'orderCurrency': 'usd', + 'decision': 'REJECT', + 'ccAuthReply_amount': '0.00', + 'reasonCode': '207' + } + result = process_postpay_callback(params) + self.assertFalse(result['success']) + self.assertEqual(result['order'], order1) + self.assertEqual(order1.status, 'cart') + self.assertIn(REASONCODE_MAP['207'], result['error_html']) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 0d046b9a4b..fa8345f33e 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -47,7 +47,7 @@ def show_cart(request): total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() - form_html = render_purchase_form_html(cart, request.user) + form_html = render_purchase_form_html(cart) return render_to_response("shoppingcart/list.html", {'shoppingcart_items': cart_items, 'amount': amount,