diff --git a/ecommerce/extensions/iap/api/v1/AppleRootCA-G3.cer b/ecommerce/extensions/iap/api/v1/AppleRootCA-G3.cer new file mode 100644 index 00000000000..228bfa39cbd Binary files /dev/null and b/ecommerce/extensions/iap/api/v1/AppleRootCA-G3.cer differ diff --git a/ecommerce/extensions/iap/api/v1/constants.py b/ecommerce/extensions/iap/api/v1/constants.py index 5aa6bb032f8..ce42c936b16 100644 --- a/ecommerce/extensions/iap/api/v1/constants.py +++ b/ecommerce/extensions/iap/api/v1/constants.py @@ -13,6 +13,8 @@ ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND = "Could not find any transaction to refund for [%s] by processor [%s]" ERROR_DURING_POST_ORDER_OP = "An error occurred during post order operations." GOOGLE_PUBLISHER_API_SCOPE = "https://www.googleapis.com/auth/androidpublisher" +IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE = "Ignoring notification from apple since we are only expecting" \ + " refund notifications" LOGGER_BASKET_ALREADY_PURCHASED = "Basket creation failed for user [%s] with SKUS [%s]. Products already purchased" LOGGER_BASKET_CREATED = "Basket created for user [%s] with SKUS [%s]" LOGGER_BASKET_CREATION_FAILED = "Basket creation failed for user [%s]. Error: [%s]" @@ -34,5 +36,6 @@ NO_PRODUCT_AVAILABLE = "No product is available to buy." PRODUCTS_DO_NOT_EXIST = "Products with SKU(s) [{skus}] do not exist." PRODUCT_IS_NOT_AVAILABLE = "Product [%s] is not available to buy." +RECEIVED_NOTIFICATION_FROM_APPLE = "Received notification from apple with notification type [%s]" SEGMENT_MOBILE_BASKET_ADD = "Mobile Basket Add Items View Called" SEGMENT_MOBILE_PURCHASE_VIEW = "Mobile Course Purchase View Called" diff --git a/ecommerce/extensions/iap/api/v1/tests/test_views.py b/ecommerce/extensions/iap/api/v1/tests/test_views.py index 0da216b32f4..e2fac4b553d 100644 --- a/ecommerce/extensions/iap/api/v1/tests/test_views.py +++ b/ecommerce/extensions/iap/api/v1/tests/test_views.py @@ -3,6 +3,7 @@ import urllib.error import urllib.parse +import app_store_notifications_v2_validator as asn2 import ddt import mock import pytz @@ -36,6 +37,7 @@ ERROR_ORDER_NOT_FOUND_FOR_REFUND, ERROR_REFUND_NOT_COMPLETED, ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND, + IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE, LOGGER_BASKET_ALREADY_PURCHASED, LOGGER_BASKET_CREATED, LOGGER_BASKET_CREATION_FAILED, @@ -51,12 +53,13 @@ LOGGER_REFUND_SUCCESSFUL, LOGGER_STARTING_PAYMENT_FLOW, NO_PRODUCT_AVAILABLE, - PRODUCTS_DO_NOT_EXIST + PRODUCTS_DO_NOT_EXIST, + RECEIVED_NOTIFICATION_FROM_APPLE ) from ecommerce.extensions.iap.api.v1.google_validator import GooglePlayValidator from ecommerce.extensions.iap.api.v1.ios_validator import IOSValidator from ecommerce.extensions.iap.api.v1.serializers import MobileOrderSerializer -from ecommerce.extensions.iap.api.v1.views import AndroidRefund, MobileCoursePurchaseExecutionView +from ecommerce.extensions.iap.api.v1.views import AndroidRefundView, MobileCoursePurchaseExecutionView from ecommerce.extensions.iap.processors.android_iap import AndroidIAP from ecommerce.extensions.iap.processors.ios_iap import IOSIAP from ecommerce.extensions.order.utils import UserAlreadyPlacedOrder @@ -671,7 +674,6 @@ def test_view_response(self): class BaseRefundTests(RefundTestMixin, AccessTokenMixin, JwtMixin, TestCase): MODEL_LOGGER_NAME = 'ecommerce.core.models' - path = reverse('iap:android-refund') def setUp(self): super(BaseRefundTests, self).setUp() @@ -685,14 +687,13 @@ def setUp(self): def assert_ok_response(self, response): """ Assert the response has HTTP status 200 and no data. """ self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json(), []) def test_transaction_id_not_found(self): """ If the transaction id doesn't match, no refund IDs should be created. """ with LogCapture(self.logger_name) as logger: - AndroidRefund().refund(self.invalid_transaction_id, {}) + AndroidRefundView().refund(self.invalid_transaction_id, {}) msg = ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND % (self.invalid_transaction_id, - AndroidRefund.processor_name) + AndroidRefundView.processor_name) logger.check((self.logger_name, 'ERROR', msg),) @staticmethod @@ -712,7 +713,6 @@ def assert_refund_and_order(self, refund, order, basket, processor_response, ref self.assertEqual(basket, processor_response.basket) self.assertEqual(refund_response.transaction_id, processor_response.transaction_id) - self.assertNotEqual(refund_response.id, processor_response.id) def test_refund_completion_error(self): """ @@ -721,7 +721,7 @@ def test_refund_completion_error(self): order = self.create_order() PaymentProcessorResponse.objects.create(basket=order.basket, transaction_id=self.valid_transaction_id, - processor_name=AndroidRefund.processor_name, + processor_name=AndroidRefundView.processor_name, response=json.dumps({'state': 'approved'})) def _revoke_lines(refund): @@ -732,16 +732,16 @@ def _revoke_lines(refund): with mock.patch.object(Refund, '_revoke_lines', side_effect=_revoke_lines, autospec=True): refund_payload = {"state": "refund"} - msg = ERROR_REFUND_NOT_COMPLETED % (self.user.username, self.course_id, AndroidRefund.processor_name) + msg = ERROR_REFUND_NOT_COMPLETED % (self.user.username, self.course_id, AndroidRefundView.processor_name) with LogCapture(self.logger_name) as logger: - AndroidRefund().refund(self.valid_transaction_id, refund_payload) + AndroidRefundView().refund(self.valid_transaction_id, refund_payload) self.assertFalse(Refund.objects.exists()) self.assertEqual(len(PaymentProcessorResponse.objects.all()), 1) # logger.check((self.logger_name, 'ERROR', msg),) # A second call should ensure the atomicity of the refund logic - AndroidRefund().refund(self.valid_transaction_id, refund_payload) + AndroidRefundView().refund(self.valid_transaction_id, refund_payload) self.assertFalse(Refund.objects.exists()) self.assertEqual(len(PaymentProcessorResponse.objects.all()), 1) logger.check( @@ -758,12 +758,12 @@ def test_valid_order(self): self.assertFalse(Refund.objects.exists()) processor_response = PaymentProcessorResponse.objects.create(basket=basket, transaction_id=self.valid_transaction_id, - processor_name=AndroidRefund.processor_name, + processor_name=AndroidRefundView.processor_name, response=json.dumps({'state': 'approved'})) with mock.patch.object(Refund, '_revoke_lines', side_effect=BaseRefundTests._revoke_lines, autospec=True): refund_payload = {"state": "refund"} - AndroidRefund().refund(self.valid_transaction_id, refund_payload) + AndroidRefundView().refund(self.valid_transaction_id, refund_payload) refund = Refund.objects.latest() refund_response = PaymentProcessorResponse.objects.latest() @@ -771,17 +771,16 @@ def test_valid_order(self): # A second call should result in no additional refunds being created with LogCapture(self.logger_name) as logger: - AndroidRefund().refund(self.valid_transaction_id, {}) - msg = ERROR_ORDER_NOT_FOUND_FOR_REFUND % (self.valid_transaction_id, AndroidRefund.processor_name) + AndroidRefundView().refund(self.valid_transaction_id, {}) + msg = ERROR_ORDER_NOT_FOUND_FOR_REFUND % (self.valid_transaction_id, AndroidRefundView.processor_name) logger.check((self.logger_name, 'ERROR', msg),) class AndroidRefundTests(BaseRefundTests): - MODEL_LOGGER_NAME = 'ecommerce.core.models' path = reverse('iap:android-refund') order_id_one = "1234" order_id_two = "5678" - mock_android_response = { + mock_processor_response = { "voidedPurchases": [ { "purchaseToken": "purchase_token", @@ -804,16 +803,12 @@ class AndroidRefundTests(BaseRefundTests): ] } logger_name = 'ecommerce.extensions.iap.api.v1.views' - processor_name = AndroidRefund.processor_name - - def assert_ok_response(self, response): - """ Assert the response has HTTP status 200 and no data. """ - self.assertEqual(response.status_code, status.HTTP_200_OK) + processor_name = AndroidIAP.NAME def check_record_not_found_log(self, logger, msg_t): response = self.client.get(self.path) self.assert_ok_response(response) - refunds = self.mock_android_response['voidedPurchases'] + refunds = self.mock_processor_response['voidedPurchases'] msgs = [msg_t % (refund['orderId'], self.processor_name) for refund in refunds] logger.check( (self.logger_name, 'ERROR', msgs[0]), @@ -830,7 +825,7 @@ def test_transaction_id_not_found(self): mock_credential_method.return_value.authorize.return_value = None mock_build.return_value.purchases.return_value.voidedpurchases.return_value\ - .list.return_value.execute.return_value = self.mock_android_response + .list.return_value.execute.return_value = self.mock_processor_response self.check_record_not_found_log(logger, ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND) def test_valid_orders(self): @@ -853,10 +848,10 @@ def test_valid_orders(self): payment_processor_responses = [] for index in range(len(baskets)): - transaction_id = self.mock_android_response['voidedPurchases'][index]['orderId'] + transaction_id = self.mock_processor_response['voidedPurchases'][index]['orderId'] payment_processor_responses.append( PaymentProcessorResponse.objects.create(basket=baskets[0], transaction_id=transaction_id, - processor_name=AndroidRefund.processor_name, + processor_name=AndroidRefundView.processor_name, response=json.dumps({'state': 'approved'}))) with mock.patch.object(Refund, '_revoke_lines', side_effect=BaseRefundTests._revoke_lines, autospec=True), \ @@ -867,7 +862,7 @@ def test_valid_orders(self): mock_credential_method.return_value.authorize.return_value = None mock_build.return_value.purchases.return_value.voidedpurchases.return_value.\ - list.return_value.execute.return_value = self.mock_android_response + list.return_value.execute.return_value = self.mock_processor_response response = self.client.get(self.path) self.assert_ok_response(response) @@ -894,3 +889,117 @@ def test_valid_orders(self): # A second call should result in no additional refunds being created with LogCapture(self.logger_name) as logger: self.check_record_not_found_log(logger, ERROR_ORDER_NOT_FOUND_FOR_REFUND) + + +class IOSRefundTests(BaseRefundTests): + path = reverse('iap:ios-refund') + order_id_one = "1234" + mock_processor_response = { + "notificationType": "REFUND", + "notificationUUID": "3e16e420", + "data": { + "bundleId": "test.mobile", + "environment": "Sandbox", + "signedTransactionInfo": { + "originalTransactionId": "1234" + } + }, + "version": "2.0", + "signedDate": 1679801012716 + } + logger_name = 'ecommerce.extensions.iap.api.v1.views' + processor_name = IOSIAP.NAME + + def assert_error_response(self, response): + """ Assert the response has HTTP status 200 and no data. """ + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + + def check_record_not_found_log(self, logger, msg_t): + response = self.client.post(self.path) + self.assert_error_response(response) + transaction = self.mock_processor_response['data']['signedTransactionInfo']['originalTransactionId'] + msg = msg_t % (transaction, self.processor_name) + info = RECEIVED_NOTIFICATION_FROM_APPLE % "REFUND" + logger.check( + (self.logger_name, "INFO", info), + (self.logger_name, 'ERROR', msg) + ) + + def test_transaction_id_not_found(self): + """ If the transaction id doesn't match, no refund IDs should be created. """ + + with mock.patch.object(asn2, 'parse') as mock_ios_response_parse, \ + LogCapture(self.logger_name) as logger: + + mock_ios_response_parse.return_value = self.mock_processor_response + self.check_record_not_found_log(logger, ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND) + + def test_valid_orders(self): + """ + View should create a refund if an order/line are found eligible for refund. + """ + order = self.create_order() + self.assertFalse(Refund.objects.exists()) + basket = BasketFactory(site=self.site, owner=self.user) + basket.add_product(self.verified_product) + + transaction_id = self.mock_processor_response['data']['signedTransactionInfo']['originalTransactionId'] + processor_response = PaymentProcessorResponse.objects.create(basket=basket, transaction_id=transaction_id, + processor_name=self.processor_name, + response=json.dumps(self.mock_processor_response)) + + with mock.patch.object(Refund, '_revoke_lines', side_effect=BaseRefundTests._revoke_lines, autospec=True), \ + mock.patch.object(asn2, 'parse') as mock_ios_response_parse, \ + LogCapture(self.logger_name) as logger: + + mock_ios_response_parse.return_value = self.mock_processor_response + response = self.client.post(self.path) + self.assert_ok_response(response) + logger.check( + ( + self.logger_name, + 'INFO', + RECEIVED_NOTIFICATION_FROM_APPLE % "REFUND" + ), + ( + self.logger_name, + 'INFO', + LOGGER_REFUND_SUCCESSFUL % (self.order_id_one, self.processor_name) + ), + ) + + refunds = Refund.objects.all() + refund_responses = PaymentProcessorResponse.objects.all() + self.assertEqual(len(refunds), 1) + self.assert_refund_and_order(refunds[0], order, basket, + processor_response, refund_responses[0]) + + # A second call should result in no additional refunds being created + with LogCapture(self.logger_name) as logger: + self.check_record_not_found_log(logger, ERROR_ORDER_NOT_FOUND_FOR_REFUND) + + def test_non_refund_notification(self): + """ + View should create a refund if an order/line are found eligible for refund. + """ + + with mock.patch.object(asn2, 'parse') as mock_ios_response_parse,\ + LogCapture(self.logger_name) as logger: + + non_refund_payload = self.mock_processor_response.copy() + non_refund_payload['notificationType'] = 'TEST' + mock_ios_response_parse.return_value = non_refund_payload + response = self.client.post(self.path) + self.assert_ok_response(response) + logger.check( + ( + self.logger_name, + 'INFO', + RECEIVED_NOTIFICATION_FROM_APPLE % "TEST" + ), + ( + self.logger_name, + 'INFO', + IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE + ) + ) diff --git a/ecommerce/extensions/iap/api/v1/urls.py b/ecommerce/extensions/iap/api/v1/urls.py index 044336da054..f9a142ef440 100644 --- a/ecommerce/extensions/iap/api/v1/urls.py +++ b/ecommerce/extensions/iap/api/v1/urls.py @@ -1,7 +1,8 @@ from django.conf.urls import url from ecommerce.extensions.iap.api.v1.views import ( - AndroidRefund, + AndroidRefundView, + IOSRefundView, MobileBasketAddItemsView, MobileCheckoutView, MobileCoursePurchaseExecutionView @@ -11,5 +12,6 @@ url(r'^basket/add/$', MobileBasketAddItemsView.as_view(), name='mobile-basket-add'), url(r'^checkout/$', MobileCheckoutView.as_view(), name='iap-checkout'), url(r'^execute/$', MobileCoursePurchaseExecutionView.as_view(), name='iap-execute'), - url(r'^android/refund/$', AndroidRefund.as_view(), name='android-refund') + url(r'^android/refund/$', AndroidRefundView.as_view(), name='android-refund'), + url(r'^ios/refund/$', IOSRefundView.as_view(), name='ios-refund'), ] diff --git a/ecommerce/extensions/iap/api/v1/views.py b/ecommerce/extensions/iap/api/v1/views.py index 8ab198da79e..d982e30f432 100644 --- a/ecommerce/extensions/iap/api/v1/views.py +++ b/ecommerce/extensions/iap/api/v1/views.py @@ -2,6 +2,7 @@ import logging import time +import app_store_notifications_v2_validator as asn2 import httplib2 from django.conf import settings from django.core.exceptions import ObjectDoesNotExist @@ -17,6 +18,7 @@ from oscar.apps.basket.views import * # pylint: disable=wildcard-import, unused-wildcard-import from oscar.apps.payment.exceptions import GatewayError, PaymentError from oscar.core.loading import get_class, get_model +from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -44,6 +46,7 @@ ERROR_REFUND_NOT_COMPLETED, ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND, GOOGLE_PUBLISHER_API_SCOPE, + IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE, LOGGER_BASKET_ALREADY_PURCHASED, LOGGER_BASKET_CREATED, LOGGER_BASKET_CREATION_FAILED, @@ -61,6 +64,7 @@ NO_PRODUCT_AVAILABLE, PRODUCT_IS_NOT_AVAILABLE, PRODUCTS_DO_NOT_EXIST, + RECEIVED_NOTIFICATION_FROM_APPLE, SEGMENT_MOBILE_BASKET_ADD, SEGMENT_MOBILE_PURCHASE_VIEW ) @@ -106,18 +110,18 @@ def get(self, request): basket = prepare_basket(request, available_products) except AlreadyPlacedOrderException: logger.exception(LOGGER_BASKET_ALREADY_PURCHASED, request.user.username, skus) - return JsonResponse({'error': _(ERROR_ALREADY_PURCHASED)}, status=406) + return JsonResponse({'error': _(ERROR_ALREADY_PURCHASED)}, status=status.HTTP_406_NOT_ACCEPTABLE) set_email_preference_on_basket(request, basket) logger.info(LOGGER_BASKET_CREATED, request.user.username, skus) return JsonResponse({'success': _(COURSE_ADDED_TO_BASKET), 'basket_id': basket.id}, - status=200) + status=status.HTTP_200_OK) except BadRequestException as exc: logger.exception(LOGGER_BASKET_CREATION_FAILED, request.user.username, str(exc)) - return JsonResponse({'error': str(exc)}, status=400) + return JsonResponse({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) def _get_skus(self, request): skus = [escape(sku) for sku in request.GET.getlist('sku')] @@ -179,10 +183,8 @@ def payment_processor(self): def _get_basket(self, request, basket_id): """ Retrieve a basket using a basket ID. - Arguments: basket_id: basket_id representing basket. - Returns: It will return related basket or raise AlreadyPlacedOrderException if products in basket have already been purchased. @@ -263,11 +265,12 @@ class BaseRefund(APIView): def refund(self, transaction_id, processor_response): """ Get a transaction id and create a refund against that transaction. """ + is_refunded = False original_purchase = PaymentProcessorResponse.objects.filter(transaction_id=transaction_id, processor_name=self.processor_name).first() if not original_purchase: logger.error(ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND, transaction_id, self.processor_name) - return + return is_refunded basket = original_purchase.basket user = basket.owner @@ -279,7 +282,7 @@ def refund(self, transaction_id, processor_response): if not refunds: monitoring_utils.set_custom_attribute('iap_no_order_to_refund', transaction_id) logger.error(ERROR_ORDER_NOT_FOUND_FOR_REFUND, transaction_id, self.processor_name) - return + return is_refunded refund = refunds[0] refund.approve(revoke_fulfillment=True) @@ -291,12 +294,15 @@ def refund(self, transaction_id, processor_response): transaction_id=transaction_id, response=processor_response, basket=basket) logger.info(LOGGER_REFUND_SUCCESSFUL, transaction_id, self.processor_name) + is_refunded = True except RefundCompletionException: logger.exception(ERROR_REFUND_NOT_COMPLETED, user.username, course_key, self.processor_name) + return is_refunded + -class AndroidRefund(BaseRefund): +class AndroidRefundView(BaseRefund): """ Create refunds for orders refunded by google and un-enroll users from relevant courses """ @@ -334,3 +340,31 @@ def _get_service(self, configuration): service = build("androidpublisher", "v3", http=http) return service + + +class IOSRefundView(BaseRefund): + processor_name = IOSIAP.NAME + + def post(self, request): + """ + This endpoint is registered as a callback for every refund made in Appstore. + It receives refund data and un enrolls user from the related course. + If we don't send back 200 response to the Appstore, it will retry this url multiple times. + """ + is_refunded = False + try: + apple_cert_file_path = "ecommerce/extensions/iap/api/v1/AppleRootCA-G3.cer" + refund_data = asn2.parse(request.body, apple_root_cert_path=apple_cert_file_path) + logger.info(RECEIVED_NOTIFICATION_FROM_APPLE, refund_data['notificationType']) + if refund_data['notificationType'] == 'REFUND': + original_transaction_id = refund_data['data']['signedTransactionInfo']['originalTransactionId'] + is_refunded = self.refund(original_transaction_id, refund_data) + else: + logger.info(IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE) + return Response(status=status.HTTP_200_OK) + + except Exception: # pylint: disable=broad-except + pass + + status_code = status.HTTP_200_OK if is_refunded else status.HTTP_500_INTERNAL_SERVER_ERROR + return Response(status=status_code) diff --git a/requirements/base.in b/requirements/base.in index 1fc6eebc7f6..e0b2cb104ad 100755 --- a/requirements/base.in +++ b/requirements/base.in @@ -1,6 +1,7 @@ -c constraints.txt analytics-python +app-store-notifications-v2-validator==0.0.7 bleach boto3>=1.17.80 coreapi diff --git a/requirements/base.txt b/requirements/base.txt index a8fce3e4441..0e8e7960499 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -12,6 +12,8 @@ amqp==2.6.1 # via kombu analytics-python==1.4.post1 # via -r requirements/base.in +app-store-notifications-v2-validator==0.0.7 + # via -r requirements/base.in asgiref==3.6.0 # via django asn1crypto==1.5.1 @@ -55,6 +57,7 @@ certifi==2023.5.7 # requests cffi==1.15.1 # via + # app-store-notifications-v2-validator # cryptography # cybersource-rest-client-python # pynacl @@ -84,6 +87,7 @@ crypto==1.4.1 # via cybersource-rest-client-python cryptography==40.0.2 # via + # app-store-notifications-v2-validator # cybersource-rest-client-python # paramiko # pyjwt @@ -412,6 +416,7 @@ pycountry==17.1.8 # via -r requirements/base.in pycparser==2.21 # via + # app-store-notifications-v2-validator # cffi # cybersource-rest-client-python pycryptodome==3.17 @@ -426,6 +431,7 @@ pyjwkest==1.4.2 # via edx-drf-extensions pyjwt[crypto]==2.7.0 # via + # app-store-notifications-v2-validator # cybersource-rest-client-python # drf-jwt # edx-auth-backends @@ -441,6 +447,7 @@ pynacl==1.5.0 # paramiko pyopenssl==23.1.1 # via + # app-store-notifications-v2-validator # cybersource-rest-client-python # ndg-httpsclient # paypalrestsdk diff --git a/requirements/dev.txt b/requirements/dev.txt index 763bf67ff7e..f8c3711ba2e 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -26,6 +26,8 @@ amqp==2.6.1 # kombu analytics-python==1.4.post1 # via -r requirements/test.txt +app-store-notifications-v2-validator==0.0.7 + # via -r requirements/test.txt asgiref==3.6.0 # via # -r requirements/test.txt @@ -104,6 +106,7 @@ certifi==2023.5.7 cffi==1.15.1 # via # -r requirements/test.txt + # app-store-notifications-v2-validator # cryptography # cybersource-rest-client-python # pynacl @@ -149,6 +152,7 @@ crypto==1.4.1 cryptography==40.0.2 # via # -r requirements/test.txt + # app-store-notifications-v2-validator # cybersource-rest-client-python # paramiko # pyjwt @@ -665,6 +669,7 @@ pycountry==17.1.8 pycparser==2.21 # via # -r requirements/test.txt + # app-store-notifications-v2-validator # cffi # cybersource-rest-client-python pycryptodome==3.17 @@ -695,6 +700,7 @@ pyjwkest==1.4.2 pyjwt[crypto]==2.7.0 # via # -r requirements/test.txt + # app-store-notifications-v2-validator # cybersource-rest-client-python # drf-jwt # edx-auth-backends @@ -716,6 +722,7 @@ pynacl==1.5.0 pyopenssl==23.1.1 # via # -r requirements/test.txt + # app-store-notifications-v2-validator # cybersource-rest-client-python # ndg-httpsclient # paypalrestsdk diff --git a/requirements/production.txt b/requirements/production.txt index d9937040aa5..39a23ca666a 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -12,6 +12,8 @@ amqp==2.6.1 # via kombu analytics-python==1.4.post1 # via -r requirements/base.in +app-store-notifications-v2-validator==0.0.7 + # via -r requirements/base.in asgiref==3.6.0 # via django asn1crypto==1.5.1 @@ -57,6 +59,7 @@ certifi==2023.5.7 # requests cffi==1.15.1 # via + # app-store-notifications-v2-validator # cryptography # cybersource-rest-client-python # pynacl @@ -86,6 +89,7 @@ crypto==1.4.1 # via cybersource-rest-client-python cryptography==40.0.2 # via + # app-store-notifications-v2-validator # cybersource-rest-client-python # paramiko # pyjwt @@ -422,6 +426,7 @@ pycountry==17.1.8 # via -r requirements/base.in pycparser==2.21 # via + # app-store-notifications-v2-validator # cffi # cybersource-rest-client-python pycryptodome==3.17 @@ -436,6 +441,7 @@ pyjwkest==1.4.2 # via edx-drf-extensions pyjwt[crypto]==2.7.0 # via + # app-store-notifications-v2-validator # cybersource-rest-client-python # drf-jwt # edx-auth-backends @@ -451,6 +457,7 @@ pynacl==1.5.0 # paramiko pyopenssl==23.1.1 # via + # app-store-notifications-v2-validator # cybersource-rest-client-python # ndg-httpsclient # paypalrestsdk diff --git a/requirements/test.txt b/requirements/test.txt index 35d4b4ec282..b79664a82f5 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -18,6 +18,8 @@ amqp==2.6.1 # kombu analytics-python==1.4.post1 # via -r requirements/base.txt +app-store-notifications-v2-validator==0.0.7 + # via -r requirements/base.txt asgiref==3.6.0 # via # -r requirements/base.txt @@ -91,6 +93,7 @@ cffi==1.15.1 # via # -r requirements/base.txt # -r requirements/e2e.txt + # app-store-notifications-v2-validator # cryptography # cybersource-rest-client-python # pynacl @@ -139,6 +142,7 @@ cryptography==40.0.2 # via # -r requirements/base.txt # -r requirements/e2e.txt + # app-store-notifications-v2-validator # cybersource-rest-client-python # paramiko # pyjwt @@ -644,6 +648,7 @@ pycparser==2.21 # via # -r requirements/base.txt # -r requirements/e2e.txt + # app-store-notifications-v2-validator # cffi # cybersource-rest-client-python pycryptodome==3.17 @@ -667,6 +672,7 @@ pyjwt[crypto]==2.7.0 # via # -r requirements/base.txt # -r requirements/e2e.txt + # app-store-notifications-v2-validator # cybersource-rest-client-python # drf-jwt # edx-auth-backends @@ -691,6 +697,7 @@ pynacl==1.5.0 pyopenssl==23.1.1 # via # -r requirements/base.txt + # app-store-notifications-v2-validator # cybersource-rest-client-python # ndg-httpsclient # paypalrestsdk