From 971dfe9ac6170e32262a57f1018355b3f30f3584 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Tue, 20 Feb 2024 21:34:09 +0000 Subject: [PATCH 01/12] refactor(enrollment): add littlepay as dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index ca36866d0..6aceb4d45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "django-csp==3.7", "django-google-sso==5.0.0", "eligibility-api==2023.9.1", + "calitp-littlepay@git+https://github.com/cal-itp/littlepay", "requests==2.31.0", "sentry-sdk==1.40.5", "six==1.16.0", From 6e238c3bd24e2836770cb44b59c0ff5c8096f592 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Wed, 21 Feb 2024 01:21:06 +0000 Subject: [PATCH 02/12] refactor(enrollment): request access token using Backoffice API updates unit test for view function. some fields were needed on PaymentProcessor to represent Backoffice API config --- ...nce_paymentprocessor_client_id_and_more.py | 35 +++++++++++++++++++ benefits/core/models.py | 7 ++++ benefits/enrollment/views.py | 12 +++++-- tests/pytest/conftest.py | 3 ++ tests/pytest/enrollment/test_views.py | 10 +++--- 5 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 benefits/core/migrations/0002_paymentprocessor_audience_paymentprocessor_client_id_and_more.py diff --git a/benefits/core/migrations/0002_paymentprocessor_audience_paymentprocessor_client_id_and_more.py b/benefits/core/migrations/0002_paymentprocessor_audience_paymentprocessor_client_id_and_more.py new file mode 100644 index 000000000..77754b1e7 --- /dev/null +++ b/benefits/core/migrations/0002_paymentprocessor_audience_paymentprocessor_client_id_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.0.2 on 2024-02-28 23:16 + +import benefits.core.models +import benefits.secrets +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="paymentprocessor", + name="audience", + field=models.TextField(default="audience"), + preserve_default=False, + ), + migrations.AddField( + model_name="paymentprocessor", + name="client_id", + field=models.TextField(default="client_id"), + preserve_default=False, + ), + migrations.AddField( + model_name="paymentprocessor", + name="client_secret_name", + field=benefits.core.models.SecretNameField( + default="client_secret_name", max_length=127, validators=[benefits.secrets.SecretNameValidator()] + ), + preserve_default=False, + ), + ] diff --git a/benefits/core/models.py b/benefits/core/models.py index 8473fc2cc..44b88701d 100644 --- a/benefits/core/models.py +++ b/benefits/core/models.py @@ -209,6 +209,9 @@ class PaymentProcessor(models.Model): api_access_token_endpoint = models.TextField() api_access_token_request_key = models.TextField() api_access_token_request_val = models.TextField() + client_id = models.TextField() + client_secret_name = SecretNameField() + audience = models.TextField() card_tokenize_url = models.TextField() card_tokenize_func = models.TextField() card_tokenize_env = models.TextField() @@ -222,6 +225,10 @@ class PaymentProcessor(models.Model): customers_endpoint = models.TextField() group_endpoint = models.TextField() + @property + def client_secret(self): + return get_secret_by_name(self.client_secret_name) + def __str__(self): return self.name diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index ebac42b02..c1ade8374 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -8,6 +8,7 @@ from django.template.response import TemplateResponse from django.urls import reverse from django.utils.decorators import decorator_from_middleware +from littlepay.api.client import Client from benefits.core import models, session from benefits.core.middleware import ( @@ -37,8 +38,15 @@ def token(request): """View handler for the enrollment auth token.""" if not session.enrollment_token_valid(request): agency = session.agency(request) - response = api.Client(agency).access_token() - session.update(request, enrollment_token=response.access_token, enrollment_token_exp=response.expiry) + payment_processor = agency.payment_processor + client = Client( + base_url=payment_processor.api_base_url, + client_id=payment_processor.client_id, + client_secret=payment_processor.client_secret, + audience=payment_processor.audience, + ) + response = client.request_card_tokenization_access() + session.update(request, enrollment_token=response.get("access_token"), enrollment_token_exp=response.get("expires_at")) data = {"token": session.enrollment_token(request)} diff --git a/tests/pytest/conftest.py b/tests/pytest/conftest.py index cc62540bc..5909b8f62 100644 --- a/tests/pytest/conftest.py +++ b/tests/pytest/conftest.py @@ -137,6 +137,9 @@ def model_PaymentProcessor(model_PemData): api_access_token_endpoint="token", api_access_token_request_key="X-API-TOKEN", api_access_token_request_val="secret-value", + client_id="client_id", + client_secret_name="client_secret_name", + audience="audience", card_tokenize_url="https://example.com/payments/tokenize.js", card_tokenize_func="tokenize", card_tokenize_env="test", diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 44fb77376..60d3ac0f7 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -49,10 +49,10 @@ def test_token_ineligible(client): def test_token_refresh(mocker, client): mocker.patch("benefits.core.session.enrollment_token_valid", return_value=False) - mock_client = mocker.patch("benefits.enrollment.views.api.Client.access_token") - mock_token = mocker.Mock() - mock_token.access_token = "access_token" - mock_token.expiry = time.time() + 10000 + mock_client = mocker.patch("benefits.enrollment.views.Client.request_card_tokenization_access") + mock_token = {} + mock_token["access_token"] = "access_token" + mock_token["expires_at"] = time.time() + 10000 mock_client.return_value = mock_token path = reverse(ROUTE_TOKEN) @@ -61,7 +61,7 @@ def test_token_refresh(mocker, client): assert response.status_code == 200 data = response.json() assert "token" in data - assert data["token"] == mock_token.access_token + assert data["token"] == mock_token["access_token"] @pytest.mark.django_db From 08d20ebd16272c05407af79a72f742b4f9286474 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Wed, 21 Feb 2024 03:24:25 +0000 Subject: [PATCH 03/12] refactor(enrollment): get API token before getting enrollment token change test so that getting API token is entirely mocked - I was not able to figure out how to mock the oauth object on Client --- benefits/enrollment/views.py | 1 + tests/pytest/enrollment/test_views.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index c1ade8374..f4e970cf5 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -45,6 +45,7 @@ def token(request): client_secret=payment_processor.client_secret, audience=payment_processor.audience, ) + client.oauth.ensure_active_token(client.token) response = client.request_card_tokenization_access() session.update(request, enrollment_token=response.get("access_token"), enrollment_token_exp=response.get("expires_at")) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 60d3ac0f7..dd852f010 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -49,11 +49,11 @@ def test_token_ineligible(client): def test_token_refresh(mocker, client): mocker.patch("benefits.core.session.enrollment_token_valid", return_value=False) - mock_client = mocker.patch("benefits.enrollment.views.Client.request_card_tokenization_access") + mock_client = mocker.patch("benefits.enrollment.views.Client") mock_token = {} mock_token["access_token"] = "access_token" mock_token["expires_at"] = time.time() + 10000 - mock_client.return_value = mock_token + mock_client.return_value.request_card_tokenization_access = lambda: mock_token path = reverse(ROUTE_TOKEN) response = client.get(path) From b79b9c2e6a59b4012e11b72ca185ea93fe70daf3 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Fri, 23 Feb 2024 20:32:09 +0000 Subject: [PATCH 04/12] chore: add commented out line that is not working but should be in test --- tests/pytest/enrollment/test_views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index dd852f010..e7edc4f96 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -62,6 +62,7 @@ def test_token_refresh(mocker, client): data = response.json() assert "token" in data assert data["token"] == mock_token["access_token"] + # mock_client.oauth.ensure_active_token.assert_called_once() @pytest.mark.django_db From fbc3a48ff532f457e9a67dd7cea5a6442e7599f4 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Fri, 23 Feb 2024 20:53:02 +0000 Subject: [PATCH 05/12] refactor(enrollment): implement enrollment using Backoffice API add handling for known case that should be treated as a success --- benefits/enrollment/views.py | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index f4e970cf5..502718257 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -9,6 +9,7 @@ from django.urls import reverse from django.utils.decorators import decorator_from_middleware from littlepay.api.client import Client +from requests.exceptions import HTTPError from benefits.core import models, session from benefits.core.middleware import ( @@ -17,7 +18,7 @@ pageview_decorator, ) from benefits.core.views import ROUTE_LOGGED_OUT -from . import analytics, api, forms +from . import analytics, forms ROUTE_INDEX = "enrollment:index" @@ -60,6 +61,7 @@ def index(request): session.update(request, origin=reverse(ROUTE_INDEX)) agency = session.agency(request) + payment_processor = agency.payment_processor # POST back after payment processor form, process card token if request.method == "POST": @@ -73,13 +75,34 @@ def index(request): logger.debug("Read tokenized card") card_token = form.cleaned_data.get("card_token") - response = api.Client(agency).enroll(card_token, eligibility.group_id) - if response.success: + client = Client( + base_url=payment_processor.api_base_url, + client_id=payment_processor.client_id, + client_secret=payment_processor.client_secret, + audience=payment_processor.audience, + ) + client.oauth.ensure_active_token(client.token) + + funding_source = client.get_funding_source_by_token(card_token) + + try: + client.link_concession_group_funding_source(funding_source_id=funding_source.id, group_id=eligibility.group_id) + except HTTPError as e: + # 409 means that customer already belongs to a concession group. + # the response JSON will look like: + # {"errors":[{"detail":"Conflict (409) - Customer already belongs to a concession group."}]} + if e.response.status_code == 409: + analytics.returned_success(request, eligibility.group_id) + return success(request) + else: + analytics.returned_error(request, str(e)) + raise Exception(f"{e}: {e.response.json()}") + except Exception as e: + analytics.returned_error(request, e) + raise e + else: analytics.returned_success(request, eligibility.group_id) return success(request) - else: - analytics.returned_error(request, response.message) - raise Exception(response.message) # GET enrollment index else: From 0c0ecb19bc5c77ff0834ecaafd1e737b9ea7a6a8 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Tue, 27 Feb 2024 01:16:07 +0000 Subject: [PATCH 06/12] refactor(enrollment): delete unused code and tests the enrollment view tests have not been updated yet so there are some expected failures. they also need to be updated to add back coverage of cases that the deleted tests used to cover, e.g. HTTP errors from API requests. some cases from the old tests, e.g. invalid responses, do not apply with the new code. --- benefits/enrollment/api.py | 280 ------------------ .../test_api_AccessTokenResponse.py | 40 --- tests/pytest/enrollment/test_api_Client.py | 232 --------------- .../enrollment/test_api_CustomerResponse.py | 51 ---- .../enrollment/test_api_GroupResponse.py | 89 ------ 5 files changed, 692 deletions(-) delete mode 100644 benefits/enrollment/api.py delete mode 100644 tests/pytest/enrollment/test_api_AccessTokenResponse.py delete mode 100644 tests/pytest/enrollment/test_api_Client.py delete mode 100644 tests/pytest/enrollment/test_api_CustomerResponse.py delete mode 100644 tests/pytest/enrollment/test_api_GroupResponse.py diff --git a/benefits/enrollment/api.py b/benefits/enrollment/api.py deleted file mode 100644 index 57487d02a..000000000 --- a/benefits/enrollment/api.py +++ /dev/null @@ -1,280 +0,0 @@ -""" -The enrollment application: Benefits Enrollment API implementation. -""" - -import logging -from tempfile import NamedTemporaryFile -import time - -from django.conf import settings -import requests - - -logger = logging.getLogger(__name__) - - -class ApiError(Exception): - """Error calling the enrollment APIs.""" - - pass - - -class AccessTokenResponse: - """Benefits Enrollment API Access Token response.""" - - def __init__(self, response): - logger.info("Read access token from response") - - try: - payload = response.json() - except ValueError: - raise ApiError("Invalid response format") - - self.access_token = payload.get("access_token") - self.token_type = payload.get("token_type") - self.expires_in = payload.get("expires_in") - if self.expires_in is not None: - logger.debug("Access token has expiry") - self.expiry = time.time() + self.expires_in - else: - logger.debug("Access token has no expiry") - self.expiry = None - - logger.info("Access token successfully read from response") - - -class CustomerResponse: - """Benefits Enrollment Customer API response.""" - - def __init__(self, response): - logger.info("Read customer details from response") - - try: - payload = response.json() - self.id = payload["id"] - except (KeyError, ValueError): - raise ApiError("Invalid response format") - - if self.id is None: - raise ApiError("Invalid response format") - - self.is_registered = str(payload.get("is_registered", "false")).lower() == "true" - - logger.info("Customer details successfully read from response") - - -class GroupResponse: - """Benefits Enrollment Customer Group API response.""" - - def __init__(self, response, requested_id, group_id, payload=None): - if payload is None: - try: - payload = response.json() - except ValueError: - raise ApiError("Invalid response format") - else: - try: - # Group API uses an error response (500) to indicate that the customer already exists in the group (!!!) - # The error message should contain the customer ID and group ID we sent via payload - error = response.json()["errors"][0] - customer_id = payload[0] - detail = error["detail"] - - failure = customer_id is None or detail is None or not (customer_id in detail and group_id in detail) - - if failure: - raise ApiError("Invalid response format") - except (KeyError, ValueError): - raise ApiError("Invalid response format") - - self.customer_ids = list(payload) - self.updated_customer_id = self.customer_ids[0] if len(self.customer_ids) == 1 else None - self.success = requested_id == self.updated_customer_id - self.message = "Updated customer_id does not match enrolled customer_id" if not self.success else "" - - -class Client: - """Benefits Enrollment API client.""" - - def __init__(self, agency): - logger.debug("Initialize Benefits Enrollment API Client") - - if agency is None: - raise ValueError("agency") - if agency.payment_processor is None: - raise ValueError("agency.payment_processor") - - self.agency = agency - self.payment_processor = agency.payment_processor - self.headers = {"Accept": "application/json", "Content-type": "application/json"} - - def _headers(self, headers=None): - h = dict(self.headers) - if headers: - h.update(headers) - return h - - def _make_url(self, *parts): - return "/".join((self.payment_processor.api_base_url, self.agency.merchant_id, *parts)) - - def _get(self, url, payload, headers=None): - h = self._headers(headers) - return self._cert_request( - lambda verify, cert: requests.get( - url, - headers=h, - params=payload, - verify=verify, - cert=cert, - timeout=settings.REQUESTS_TIMEOUT, - ) - ) - - def _patch(self, url, payload, headers=None): - h = self._headers(headers) - return self._cert_request( - lambda verify, cert: requests.patch( - url, - headers=h, - json=payload, - verify=verify, - cert=cert, - timeout=settings.REQUESTS_TIMEOUT, - ) - ) - - def _post(self, url, payload, headers=None): - h = self._headers(headers) - return self._cert_request( - lambda verify, cert: requests.post( - url, - headers=h, - json=payload, - verify=verify, - cert=cert, - timeout=settings.REQUESTS_TIMEOUT, - ) - ) - - def _cert_request(self, request_func): - """ - Creates named (on-disk) temp files for client cert auth. - * request_func: curried callable from `requests` library (e.g. `requests.get`). - """ - # requests library reads temp files from file path - # The "with" context destroys temp files when response comes back - with NamedTemporaryFile("w+") as cert, NamedTemporaryFile("w+") as key, NamedTemporaryFile("w+") as ca: - # write client cert data to temp files - # resetting so they can be read again by requests - cert.write(self.payment_processor.client_cert.data) - cert.seek(0) - - key.write(self.payment_processor.client_cert_private_key.data) - key.seek(0) - - ca.write(self.payment_processor.client_cert_root_ca.data) - ca.seek(0) - - # request using temp file paths - return request_func(verify=ca.name, cert=(cert.name, key.name)) - - def _get_customer(self, token): - """Get a customer record from Payment Processor's system""" - logger.info("Check for existing customer record") - - if token is None: - raise ValueError("token") - - url = self._make_url(self.payment_processor.customers_endpoint) - payload = {"token": token} - - try: - r = self._get(url, payload) - r.raise_for_status() - - logger.debug("Customer record exists") - customer = CustomerResponse(r) - if customer.is_registered: - logger.debug("Customer is registered, skip update") - return customer - else: - logger.debug("Customer is not registered, update") - return self._update_customer(customer.id) - - except requests.ConnectionError: - raise ApiError("Connection to enrollment server failed") - except requests.Timeout: - raise ApiError("Connection to enrollment server timed out") - except requests.TooManyRedirects: - raise ApiError("Too many redirects to enrollment server") - except requests.HTTPError as e: - raise ApiError(e) - - def _update_customer(self, customer_id): - """Update a customer using their unique info.""" - logger.info("Update existing customer record") - - if customer_id is None: - raise ValueError("customer_id") - - url = self._make_url(self.payment_processor.customer_endpoint, customer_id) - payload = {"is_registered": True, "id": customer_id} - - r = self._patch(url, payload) - r.raise_for_status() - - return CustomerResponse(r) - - def access_token(self): - """Obtain an access token to use for integrating with other APIs.""" - logger.info("Get new access token") - - url = self._make_url(self.payment_processor.api_access_token_endpoint) - payload = {self.payment_processor.api_access_token_request_key: self.payment_processor.api_access_token_request_val} - - try: - r = self._post(url, payload) - r.raise_for_status() - except requests.ConnectionError: - raise ApiError("Connection to enrollment server failed") - except requests.Timeout: - raise ApiError("Connection to enrollment server timed out") - except requests.TooManyRedirects: - raise ApiError("Too many redirects to enrollment server") - except requests.HTTPError as e: - raise ApiError(e) - - return AccessTokenResponse(r) - - def enroll(self, customer_token, group_id): - """Enroll a customer in a product group using the token that represents that customer.""" - logger.info("Enroll customer in product group") - - if customer_token is None: - raise ValueError("customer_token") - if group_id is None: - raise ValueError("group_id") - - customer = self._get_customer(customer_token) - url = self._make_url(self.payment_processor.group_endpoint, group_id) - payload = [customer.id] - - try: - r = self._patch(url, payload) - - if r.status_code in (200, 201): - logger.info("Customer enrolled in group") - return GroupResponse(r, customer.id, group_id) - elif r.status_code == 500: - logger.info("Customer already exists in group") - return GroupResponse(r, customer.id, group_id, payload=payload) - else: - r.raise_for_status() - except requests.ConnectionError: - raise ApiError("Connection to enrollment server failed") - except requests.Timeout: - raise ApiError("Connection to enrollment server timed out") - except requests.TooManyRedirects: - raise ApiError("Too many redirects to enrollment server") - except requests.HTTPError as e: - raise ApiError(e) diff --git a/tests/pytest/enrollment/test_api_AccessTokenResponse.py b/tests/pytest/enrollment/test_api_AccessTokenResponse.py deleted file mode 100644 index a7e9c42c4..000000000 --- a/tests/pytest/enrollment/test_api_AccessTokenResponse.py +++ /dev/null @@ -1,40 +0,0 @@ -import time - -import requests - -import pytest - -from benefits.enrollment.api import ApiError, AccessTokenResponse - - -REQUESTS_ERRORS = [requests.ConnectionError, requests.Timeout, requests.TooManyRedirects, requests.HTTPError] - - -def test_invalid_response(mocker): - mock_response = mocker.Mock() - mock_response.json.side_effect = ValueError - - with pytest.raises(ApiError, match=r"response"): - AccessTokenResponse(mock_response) - - -def test_valid_response(mocker): - mock_response = mocker.Mock() - mock_response.json.return_value = {"access_token": "access123", "token_type": "mock"} - - response = AccessTokenResponse(mock_response) - - assert response.access_token == "access123" - assert response.token_type == "mock" - assert response.expiry is None - - -def test_valid_response_expires_in(mocker): - expires_in = 100 - mock_response = mocker.Mock() - mock_response.json.return_value = {"expires_in": expires_in} - - start = time.time() - response = AccessTokenResponse(mock_response) - - assert response.expiry >= start + expires_in diff --git a/tests/pytest/enrollment/test_api_Client.py b/tests/pytest/enrollment/test_api_Client.py deleted file mode 100644 index 44eb72224..000000000 --- a/tests/pytest/enrollment/test_api_Client.py +++ /dev/null @@ -1,232 +0,0 @@ -import requests - -import pytest - -from benefits.enrollment.api import ApiError, Client, CustomerResponse, GroupResponse - - -REQUESTS_ERRORS = [requests.ConnectionError, requests.Timeout, requests.TooManyRedirects, requests.HTTPError] - - -@pytest.fixture -def api_client(model_TransitAgency): - return Client(model_TransitAgency) - - -@pytest.fixture -def mocked_customer(mocker): - mock = mocker.Mock(spec=CustomerResponse) - mocker.patch("benefits.enrollment.api.CustomerResponse", return_value=mock) - return mock - - -@pytest.fixture -def mocked_group(mocker): - mock = mocker.Mock(spec=GroupResponse) - mocker.patch("benefits.enrollment.api.GroupResponse", return_value=mock) - return mock - - -def test_init_no_agency(): - with pytest.raises(ValueError, match=r"agency"): - Client(None) - - -def test_init_no_payment_processor(mocker): - mock_agency = mocker.Mock() - mock_agency.payment_processor = None - - with pytest.raises(ValueError, match=r"payment_processor"): - Client(mock_agency) - - -def test_init(mocker): - mock_agency = mocker.Mock() - mock_agency.payment_processor = mocker.Mock() - - client = Client(mock_agency) - - assert client.agency == mock_agency - assert client.payment_processor == mock_agency.payment_processor - assert isinstance(client.headers, dict) - - -@pytest.mark.django_db -def test_headers_none(api_client): - headers = api_client._headers() - - assert headers == api_client.headers - - -@pytest.mark.django_db -def test_headers(api_client): - headers = api_client._headers({"header": "value"}) - - assert "header" in headers - assert headers["header"] == "value" - - -@pytest.mark.django_db -def test_make_url(api_client): - part1, part2, part3 = "part1", "part2", "part3" - agency = api_client.agency - - url = api_client._make_url(part1, part2, part3) - - assert agency.payment_processor.api_base_url in url - assert agency.merchant_id in url - assert part1 in url - assert part2 in url - assert part3 in url - - -@pytest.mark.django_db -def test_cert_request(mocker, api_client): - temp_file = mocker.patch("benefits.enrollment.api.NamedTemporaryFile") - request_func = mocker.Mock() - - api_client._cert_request(request_func) - - temp_file.assert_called() - request_func.assert_called_once() - assert "verify" in request_func.call_args.kwargs - assert "cert" in request_func.call_args.kwargs - - -@pytest.mark.django_db -def test_get_customer_no_token(api_client): - with pytest.raises(ValueError, match=r"token"): - api_client._get_customer(None) - - -@pytest.mark.django_db -def test_get_customer_status_not_OK(mocker, api_client): - mock_response = mocker.Mock() - mock_response.raise_for_status.side_effect = requests.HTTPError() - mocker.patch.object(api_client, "_get", return_value=mock_response) - - with pytest.raises(ApiError): - api_client._get_customer("token") - - -@pytest.mark.django_db -def test_get_customer_is_registered(mocker, api_client, mocked_customer): - mock_get_response = mocker.Mock() - mocker.patch.object(api_client, "_get", return_value=mock_get_response) - mocked_customer.is_registered = True - - return_customer = api_client._get_customer("token") - - assert return_customer == mocked_customer - assert return_customer.is_registered - - -@pytest.mark.django_db -def test_get_customer_is_not_registered(mocker, api_client, mocked_customer): - mock_get_response = mocker.Mock() - mocker.patch.object(api_client, "_get", return_value=mock_get_response) - mocked_customer.is_registered = False - mocked_customer.id = "id" - - update_spy = mocker.patch("benefits.enrollment.api.Client._update_customer") - - api_client._get_customer("token") - - update_spy.assert_called_once_with(mocked_customer.id) - - -@pytest.mark.django_db -@pytest.mark.parametrize("exception", REQUESTS_ERRORS) -def test_get_customer_exception(mocker, api_client, exception): - mocker.patch.object(api_client, "_get", side_effect=exception) - - with pytest.raises(ApiError): - api_client._get_customer("token") - - -@pytest.mark.django_db -def test_update_customer_no_customer_id(api_client): - with pytest.raises(ValueError): - api_client._update_customer(None) - - -@pytest.mark.django_db -def test_update_customer(mocker, api_client, mocked_customer): - mock_response = mocker.Mock() - mocker.patch.object(api_client, "_patch", return_value=mock_response) - - updated_customer = api_client._update_customer("id") - - assert updated_customer == mocked_customer - - -@pytest.mark.django_db -@pytest.mark.parametrize("exception", REQUESTS_ERRORS) -def test_access_token_exception(mocker, api_client, exception): - mock_response = mocker.Mock() - mock_response.raise_for_status.side_effect = exception - mocker.patch.object(api_client, "_post", return_value=mock_response) - - with pytest.raises(ApiError): - api_client.access_token() - - -@pytest.mark.django_db -def test_access_token(mocker, api_client): - mock_response = mocker.Mock() - mocker.patch.object(api_client, "_post", return_value=mock_response) - mocker.patch("benefits.enrollment.api.AccessTokenResponse") - - token = api_client.access_token() - - assert token - - -@pytest.mark.django_db -def test_enroll_no_customer_token(api_client): - with pytest.raises(ValueError, match=r"customer_token"): - api_client.enroll(None, "group_id") - - -@pytest.mark.django_db -def test_enroll_no_group_id(api_client): - with pytest.raises(ValueError, match=r"group_id"): - api_client.enroll("customer_token", None) - - -@pytest.mark.django_db -@pytest.mark.parametrize("exception", REQUESTS_ERRORS) -def test_enroll_exception(mocker, api_client, exception): - mock_response = mocker.Mock() - mock_response.raise_for_status.side_effect = exception - mocker.patch.object(api_client, "_patch", return_value=mock_response) - mocker.patch.object(api_client, "_get_customer", return_value=mocker.Mock(id="customer_id")) - - with pytest.raises(ApiError): - api_client.enroll("token", "group") - - -@pytest.mark.django_db -@pytest.mark.usefixtures("mocked_group") -@pytest.mark.parametrize("status_code", [200, 201]) -def test_enroll_customer_enrolled(mocker, api_client, status_code): - mock_response = mocker.Mock(status_code=status_code) - mocker.patch.object(api_client, "_patch", return_value=mock_response) - mocker.patch.object(api_client, "_get_customer", return_value=mocker.Mock(id="customer_id")) - - response = api_client.enroll("customer_id", "group_id") - - assert isinstance(response, GroupResponse) - - -@pytest.mark.django_db -@pytest.mark.usefixtures("mocked_group") -def test_enroll_customer_exists(mocker, api_client): - # the enrollment API uses a 500 response code (!!!) to indicate the customer already exists - mock_response = mocker.Mock(status_code=500) - mocker.patch.object(api_client, "_patch", return_value=mock_response) - mocker.patch.object(api_client, "_get_customer", return_value=mocker.Mock(id="customer_id")) - - response = api_client.enroll("customer_id", "group_id") - - assert isinstance(response, GroupResponse) diff --git a/tests/pytest/enrollment/test_api_CustomerResponse.py b/tests/pytest/enrollment/test_api_CustomerResponse.py deleted file mode 100644 index d51be7c8b..000000000 --- a/tests/pytest/enrollment/test_api_CustomerResponse.py +++ /dev/null @@ -1,51 +0,0 @@ -import pytest - -from benefits.enrollment.api import ApiError, CustomerResponse - - -@pytest.mark.parametrize("exception", [KeyError, ValueError]) -def test_invalid_response(mocker, exception): - mock_response = mocker.Mock() - mock_response.json.side_effect = exception - - with pytest.raises(ApiError, match=r"response"): - CustomerResponse(mock_response) - - -def test_no_id(mocker): - mock_response = mocker.Mock() - mock_response.json.return_value = {"id": None} - - with pytest.raises(ApiError, match=r"response"): - CustomerResponse(mock_response) - - -def test_is_registered_default(mocker): - id = "12345" - mock_response = mocker.Mock() - mock_response.json.return_value = {"id": id} - - response = CustomerResponse(mock_response) - - assert response.id == id - assert not response.is_registered - - -@pytest.mark.parametrize("is_registered", ["true", "True", "tRuE"]) -def test_is_registered(mocker, is_registered): - mock_response = mocker.Mock() - mock_response.json.return_value = {"id": "12345", "is_registered": is_registered} - - response = CustomerResponse(mock_response) - - assert response.is_registered - - -@pytest.mark.parametrize("is_registered", ["false", "Frue", "fAlSe"]) -def test_is_not_registered(mocker, is_registered): - mock_response = mocker.Mock() - mock_response.json.return_value = {"id": "12345", "is_registered": is_registered} - - response = CustomerResponse(mock_response) - - assert not response.is_registered diff --git a/tests/pytest/enrollment/test_api_GroupResponse.py b/tests/pytest/enrollment/test_api_GroupResponse.py deleted file mode 100644 index 4a5aaeeb0..000000000 --- a/tests/pytest/enrollment/test_api_GroupResponse.py +++ /dev/null @@ -1,89 +0,0 @@ -import pytest - -from benefits.enrollment.api import ApiError, GroupResponse - - -def test_no_payload_invalid_response(mocker): - mock_response = mocker.Mock() - mock_response.json.side_effect = ValueError - - with pytest.raises(ApiError, match=r"response"): - GroupResponse(mock_response, "customer", "group") - - -def test_no_payload_valid_response_single_matching_id(mocker): - mock_response = mocker.Mock() - mock_response.json.return_value = ["0"] - - response = GroupResponse(mock_response, "0", "group") - - assert response.customer_ids == ["0"] - assert response.updated_customer_id == "0" - assert response.success - assert response.message == "" - - -def test_no_payload_valid_response_single_unmatching_id(mocker): - mock_response = mocker.Mock() - mock_response.json.return_value = ["1"] - - response = GroupResponse(mock_response, "0", "group") - - assert response.customer_ids == ["1"] - assert response.updated_customer_id == "1" - assert not response.success - assert "customer_id" in response.message - - -def test_no_payload_valid_response_multiple_ids(mocker): - mock_response = mocker.Mock() - mock_response.json.return_value = ["0", "1"] - - response = GroupResponse(mock_response, "0", "group") - - assert response.customer_ids == ["0", "1"] - assert not response.updated_customer_id - assert not response.success - assert "customer_id" in response.message - - -@pytest.mark.parametrize("exception", [KeyError, ValueError]) -def test_payload_invalid_response(mocker, exception): - mock_response = mocker.Mock() - mock_response.json.side_effect = exception - - with pytest.raises(ApiError, match=r"response"): - GroupResponse(mock_response, "0", "group", []) - - -def test_payload_valid_response(mocker): - mock_response = mocker.Mock() - mock_response.json.return_value = {"errors": [{"detail": "0 group"}]} - - response = GroupResponse(mock_response, "0", "group", ["0"]) - - assert response.customer_ids == ["0"] - assert response.updated_customer_id == "0" - assert response.success - assert response.message == "" - - -failure_conditions = [ - # detail is None - ({"detail": None}, ["0"]), - # customer_id is None - ({"detail": "0 group"}, [None]), - # customer_id not in detail - ({"detail": "1 group"}, ["0"]), - # group_id not in detail - ({"detail": "0"}, ["0"]), -] - - -@pytest.mark.parametrize("error,payload", failure_conditions) -def test_payload_failure_response(mocker, error, payload): - mock_response = mocker.Mock() - mock_response.json.return_value = {"errors": [error]} - - with pytest.raises(ApiError, match=r"response"): - GroupResponse(mock_response, "0", "group", payload) From a8b7b528e3108139fbcb78997f40233010737924 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Wed, 28 Feb 2024 21:44:16 +0000 Subject: [PATCH 07/12] test(enrollment): update existing view tests to reflect Backoffice API --- tests/pytest/enrollment/test_views.py | 46 ++++++++++++++++++++------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index e7edc4f96..abef07e37 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -2,6 +2,8 @@ from django.urls import reverse +from littlepay.api.funding_sources import FundingSourceResponse +from requests import HTTPError import pytest from benefits.core.middleware import TEMPLATE_USER_ERROR @@ -49,11 +51,12 @@ def test_token_ineligible(client): def test_token_refresh(mocker, client): mocker.patch("benefits.core.session.enrollment_token_valid", return_value=False) - mock_client = mocker.patch("benefits.enrollment.views.Client") + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value mock_token = {} mock_token["access_token"] = "access_token" mock_token["expires_at"] = time.time() + 10000 - mock_client.return_value.request_card_tokenization_access = lambda: mock_token + mock_client.request_card_tokenization_access.return_value = mock_token path = reverse(ROUTE_TOKEN) response = client.get(path) @@ -62,7 +65,7 @@ def test_token_refresh(mocker, client): data = response.json() assert "token" in data assert data["token"] == mock_token["access_token"] - # mock_client.oauth.ensure_active_token.assert_called_once() + mock_client.oauth.ensure_active_token.assert_called_once() @pytest.mark.django_db @@ -110,13 +113,19 @@ def test_index_eligible_post_invalid_form(client, invalid_form_data): @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility") def test_index_eligible_post_valid_form_failure(mocker, client, card_tokenize_form_data): - mock_response = mocker.Mock() - mock_response.success = False - mock_response.message = "Mock error message" - mocker.patch("benefits.enrollment.views.api.Client.enroll", return_value=mock_response) + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + + # any status_code that isn't 409 is considered an error + mock_error = {"message": "Mock error message"} + mock_error_response = mocker.Mock(status_code=400, **mock_error) + mock_error_response.json.return_value = mock_error + mock_client.link_concession_group_funding_source.side_effect = HTTPError( + response=mock_error_response, + ) path = reverse(ROUTE_INDEX) - with pytest.raises(Exception, match=mock_response.message): + with pytest.raises(Exception, match=mock_error["message"]): client.post(path, card_tokenize_form_data) @@ -125,13 +134,28 @@ def test_index_eligible_post_valid_form_failure(mocker, client, card_tokenize_fo def test_index_eligible_post_valid_form_success( mocker, client, card_tokenize_form_data, mocked_analytics_module, model_EligibilityType ): - mock_response = mocker.Mock() - mock_response.success = True - mocker.patch("benefits.enrollment.views.api.Client.enroll", return_value=mock_response) + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + mock_funding_source = FundingSourceResponse( + id="0", + card_first_digits="0000", + card_last_digits="0000", + card_expiry_month="12", + card_expiry_year="2024", + card_scheme="visa", + form_factor="physical", + participant_id="cst", + is_fpan=False, + related_funding_sources=[], + ) + mock_client.get_funding_source_by_token.return_value = mock_funding_source path = reverse(ROUTE_INDEX) response = client.post(path, card_tokenize_form_data) + mock_client.link_concession_group_funding_source.assert_called_once_with( + funding_source_id=mock_funding_source.id, group_id=model_EligibilityType.group_id + ) assert response.status_code == 200 assert response.template_name == TEMPLATE_SUCCESS mocked_analytics_module.returned_success.assert_called_once() From a3809b2a646f679a4d780f3da25d13828d9df75b Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Wed, 28 Feb 2024 22:06:14 +0000 Subject: [PATCH 08/12] test(enrollment): add test coverage for customer already enrolled --- tests/pytest/enrollment/test_views.py | 57 ++++++++++++++++++++------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index abef07e37..3308f7291 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -36,6 +36,22 @@ def mocked_analytics_module(mocked_analytics_module): return mocked_analytics_module(benefits.enrollment.views) +@pytest.fixture +def mocked_funding_source(): + return FundingSourceResponse( + id="0", + card_first_digits="0000", + card_last_digits="0000", + card_expiry_month="12", + card_expiry_year="2024", + card_scheme="visa", + form_factor="physical", + participant_id="cst", + is_fpan=False, + related_funding_sources=[], + ) + + @pytest.mark.django_db def test_token_ineligible(client): path = reverse(ROUTE_TOKEN) @@ -131,30 +147,41 @@ def test_index_eligible_post_valid_form_failure(mocker, client, card_tokenize_fo @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") -def test_index_eligible_post_valid_form_success( - mocker, client, card_tokenize_form_data, mocked_analytics_module, model_EligibilityType +def test_index_eligible_post_valid_form_customer_already_enrolled( + mocker, client, card_tokenize_form_data, mocked_analytics_module, model_EligibilityType, mocked_funding_source ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value - mock_funding_source = FundingSourceResponse( - id="0", - card_first_digits="0000", - card_last_digits="0000", - card_expiry_month="12", - card_expiry_year="2024", - card_scheme="visa", - form_factor="physical", - participant_id="cst", - is_fpan=False, - related_funding_sources=[], + mock_client.get_funding_source_by_token.return_value = mocked_funding_source + mock_error_response = mocker.Mock(status_code=409) + mock_client.link_concession_group_funding_source.side_effect = HTTPError(response=mock_error_response) + + path = reverse(ROUTE_INDEX) + response = client.post(path, card_tokenize_form_data) + + mock_client.link_concession_group_funding_source.assert_called_once_with( + funding_source_id=mocked_funding_source.id, group_id=model_EligibilityType.group_id ) - mock_client.get_funding_source_by_token.return_value = mock_funding_source + assert response.status_code == 200 + assert response.template_name == TEMPLATE_SUCCESS + mocked_analytics_module.returned_success.assert_called_once() + assert model_EligibilityType.group_id in mocked_analytics_module.returned_success.call_args.args + + +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") +def test_index_eligible_post_valid_form_success( + mocker, client, card_tokenize_form_data, mocked_analytics_module, model_EligibilityType, mocked_funding_source +): + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + mock_client.get_funding_source_by_token.return_value = mocked_funding_source path = reverse(ROUTE_INDEX) response = client.post(path, card_tokenize_form_data) mock_client.link_concession_group_funding_source.assert_called_once_with( - funding_source_id=mock_funding_source.id, group_id=model_EligibilityType.group_id + funding_source_id=mocked_funding_source.id, group_id=model_EligibilityType.group_id ) assert response.status_code == 200 assert response.template_name == TEMPLATE_SUCCESS From 6c6bb80a50728bb73e03e2bbc8bfc23307995738 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Wed, 28 Feb 2024 22:16:56 +0000 Subject: [PATCH 09/12] test(enrollment): add test coverage for non-HTTPError failure --- benefits/enrollment/views.py | 2 +- tests/pytest/enrollment/test_views.py | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index 502718257..71ac35e16 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -98,7 +98,7 @@ def index(request): analytics.returned_error(request, str(e)) raise Exception(f"{e}: {e.response.json()}") except Exception as e: - analytics.returned_error(request, e) + analytics.returned_error(request, str(e)) raise e else: analytics.returned_success(request, eligibility.group_id) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 3308f7291..84799d99f 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -128,7 +128,7 @@ def test_index_eligible_post_invalid_form(client, invalid_form_data): @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility") -def test_index_eligible_post_valid_form_failure(mocker, client, card_tokenize_form_data): +def test_index_eligible_post_valid_form_http_error(mocker, client, card_tokenize_form_data): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value @@ -145,6 +145,19 @@ def test_index_eligible_post_valid_form_failure(mocker, client, card_tokenize_fo client.post(path, card_tokenize_form_data) +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility") +def test_index_eligible_post_valid_form_failure(mocker, client, card_tokenize_form_data): + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + + mock_client.link_concession_group_funding_source.side_effect = Exception("some other exception") + + path = reverse(ROUTE_INDEX) + with pytest.raises(Exception, match=r"some other exception"): + client.post(path, card_tokenize_form_data) + + @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") def test_index_eligible_post_valid_form_customer_already_enrolled( From ed266425587aee8d315eca23ca20d2961d7bbbc3 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Wed, 28 Feb 2024 23:00:14 +0000 Subject: [PATCH 10/12] refactor(model): update PaymentProcessor model and migration remove Customer-API-related fields and add new Backoffice API fields --- ...ssor_api_access_token_endpoint_and_more.py | 49 +++++++ benefits/core/migrations/local_fixtures.json | 123 ++---------------- benefits/core/models.py | 12 -- tests/pytest/conftest.py | 11 +- 4 files changed, 62 insertions(+), 133 deletions(-) create mode 100644 benefits/core/migrations/0003_remove_paymentprocessor_api_access_token_endpoint_and_more.py diff --git a/benefits/core/migrations/0003_remove_paymentprocessor_api_access_token_endpoint_and_more.py b/benefits/core/migrations/0003_remove_paymentprocessor_api_access_token_endpoint_and_more.py new file mode 100644 index 000000000..45ef8893c --- /dev/null +++ b/benefits/core/migrations/0003_remove_paymentprocessor_api_access_token_endpoint_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 5.0.2 on 2024-02-28 23:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0002_paymentprocessor_audience_paymentprocessor_client_id_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="paymentprocessor", + name="api_access_token_endpoint", + ), + migrations.RemoveField( + model_name="paymentprocessor", + name="api_access_token_request_key", + ), + migrations.RemoveField( + model_name="paymentprocessor", + name="api_access_token_request_val", + ), + migrations.RemoveField( + model_name="paymentprocessor", + name="client_cert", + ), + migrations.RemoveField( + model_name="paymentprocessor", + name="client_cert_private_key", + ), + migrations.RemoveField( + model_name="paymentprocessor", + name="client_cert_root_ca", + ), + migrations.RemoveField( + model_name="paymentprocessor", + name="customer_endpoint", + ), + migrations.RemoveField( + model_name="paymentprocessor", + name="customers_endpoint", + ), + migrations.RemoveField( + model_name="paymentprocessor", + name="group_endpoint", + ), + ] diff --git a/benefits/core/migrations/local_fixtures.json b/benefits/core/migrations/local_fixtures.json index 0aaf9a808..54ea2faa9 100644 --- a/benefits/core/migrations/local_fixtures.json +++ b/benefits/core/migrations/local_fixtures.json @@ -35,87 +35,6 @@ "remote_url": null } }, - { - "model": "core.pemdata", - "pk": 5, - "fields": { - "label": "(MST) payment processor client certificate", - "text_secret_name": "mst-payment-processor-client-cert", - "remote_url": null - } - }, - { - "model": "core.pemdata", - "pk": 6, - "fields": { - "label": "(MST) payment processor client certificate private key", - "text_secret_name": "mst-payment-processor-client-cert-private-key", - "remote_url": null - } - }, - { - "model": "core.pemdata", - "pk": 7, - "fields": { - "label": "(MST) payment processor client certificate root CA", - "text_secret_name": "mst-payment-processor-client-cert-root-ca", - "remote_url": null - } - }, - { - "model": "core.pemdata", - "pk": 8, - "fields": { - "label": "(SacRT) payment processor client certificate", - "text_secret_name": "sacrt-payment-processor-client-cert", - "remote_url": null - } - }, - { - "model": "core.pemdata", - "pk": 9, - "fields": { - "label": "(SacRT) payment processor client certificate private key", - "text_secret_name": "sacrt-payment-processor-client-cert-private-key", - "remote_url": null - } - }, - { - "model": "core.pemdata", - "pk": 10, - "fields": { - "label": "(SacRT) payment processor client certificate root CA", - "text_secret_name": "sacrt-payment-processor-client-cert-root-ca", - "remote_url": null - } - }, - { - "model": "core.pemdata", - "pk": 11, - "fields": { - "label": "(SBMTD) payment processor client certificate", - "text_secret_name": "sbmtd-payment-processor-client-cert", - "remote_url": null - } - }, - { - "model": "core.pemdata", - "pk": 12, - "fields": { - "label": "(SBMTD) payment processor client certificate private key", - "text_secret_name": "sbmtd-payment-processor-client-cert-private-key", - "remote_url": null - } - }, - { - "model": "core.pemdata", - "pk": 13, - "fields": { - "label": "(SBMTD) payment processor client certificate root CA", - "text_secret_name": "sbmtd-payment-processor-client-cert-root-ca", - "remote_url": null - } - }, { "model": "core.authprovider", "pk": 1, @@ -324,18 +243,12 @@ "fields": { "name": "(MST) test payment processor", "api_base_url": "http://server:8000", - "api_access_token_endpoint": "access-token", - "api_access_token_request_key": "request_access", - "api_access_token_request_val": "REQUEST_ACCESS", + "client_id": "", + "client_secret_name": "", + "audience": "", "card_tokenize_url": "http://server:8000/static/tokenize.js", "card_tokenize_func": "tokenize", - "card_tokenize_env": "test", - "client_cert": 5, - "client_cert_private_key": 6, - "client_cert_root_ca": 7, - "customer_endpoint": "customer", - "customers_endpoint": "customers", - "group_endpoint": "group" + "card_tokenize_env": "test" } }, { @@ -344,18 +257,12 @@ "fields": { "name": "(SacRT) test payment processor", "api_base_url": "http://server:8000", - "api_access_token_endpoint": "access-token", - "api_access_token_request_key": "request_access", - "api_access_token_request_val": "REQUEST_ACCESS", + "client_id": "", + "client_secret_name": "", + "audience": "", "card_tokenize_url": "http://server:8000/static/tokenize.js", "card_tokenize_func": "tokenize", - "card_tokenize_env": "test", - "client_cert": 8, - "client_cert_private_key": 9, - "client_cert_root_ca": 10, - "customer_endpoint": "customer", - "customers_endpoint": "customers", - "group_endpoint": "group" + "card_tokenize_env": "test" } }, { @@ -364,18 +271,12 @@ "fields": { "name": "(SBMTD) test payment processor", "api_base_url": "http://server:8000", - "api_access_token_endpoint": "access-token", - "api_access_token_request_key": "request_access", - "api_access_token_request_val": "REQUEST_ACCESS", + "client_id": "", + "client_secret_name": "", + "audience": "", "card_tokenize_url": "http://server:8000/static/tokenize.js", "card_tokenize_func": "tokenize", - "card_tokenize_env": "test", - "client_cert": 11, - "client_cert_private_key": 12, - "client_cert_root_ca": 13, - "customer_endpoint": "customer", - "customers_endpoint": "customers", - "group_endpoint": "group" + "card_tokenize_env": "test" } }, { diff --git a/benefits/core/models.py b/benefits/core/models.py index 44b88701d..72561c93a 100644 --- a/benefits/core/models.py +++ b/benefits/core/models.py @@ -206,24 +206,12 @@ class PaymentProcessor(models.Model): id = models.AutoField(primary_key=True) name = models.TextField() api_base_url = models.TextField() - api_access_token_endpoint = models.TextField() - api_access_token_request_key = models.TextField() - api_access_token_request_val = models.TextField() client_id = models.TextField() client_secret_name = SecretNameField() audience = models.TextField() card_tokenize_url = models.TextField() card_tokenize_func = models.TextField() card_tokenize_env = models.TextField() - # The certificate used for client certificate authentication to the API - client_cert = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT) - # The private key, used to sign the certificate - client_cert_private_key = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT) - # The root CA bundle, used to verify the server. - client_cert_root_ca = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT) - customer_endpoint = models.TextField() - customers_endpoint = models.TextField() - group_endpoint = models.TextField() @property def client_secret(self): diff --git a/tests/pytest/conftest.py b/tests/pytest/conftest.py index 5909b8f62..0dcf3aa8b 100644 --- a/tests/pytest/conftest.py +++ b/tests/pytest/conftest.py @@ -130,25 +130,16 @@ def model_EligibilityVerifier_AuthProvider_with_verification(model_AuthProvider_ @pytest.fixture -def model_PaymentProcessor(model_PemData): +def model_PaymentProcessor(): payment_processor = PaymentProcessor.objects.create( name="Test Payment Processor", api_base_url="https://example.com/payments", - api_access_token_endpoint="token", - api_access_token_request_key="X-API-TOKEN", - api_access_token_request_val="secret-value", client_id="client_id", client_secret_name="client_secret_name", audience="audience", card_tokenize_url="https://example.com/payments/tokenize.js", card_tokenize_func="tokenize", card_tokenize_env="test", - client_cert=model_PemData, - client_cert_private_key=model_PemData, - client_cert_root_ca=model_PemData, - customer_endpoint="customer", - customers_endpoint="customers", - group_endpoint="group", ) return payment_processor From cdb379e75978eec695649bd6b7407c173e2af937 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 7 Mar 2024 21:39:01 +0000 Subject: [PATCH 11/12] refactor(migrations): combine migrations related to Backoffice refactor --- ...nce_paymentprocessor_client_id_and_more.py | 35 ------------------- ...> 0002_paymentprocessor_backoffice_api.py} | 28 +++++++++++++-- 2 files changed, 25 insertions(+), 38 deletions(-) delete mode 100644 benefits/core/migrations/0002_paymentprocessor_audience_paymentprocessor_client_id_and_more.py rename benefits/core/migrations/{0003_remove_paymentprocessor_api_access_token_endpoint_and_more.py => 0002_paymentprocessor_backoffice_api.py} (57%) diff --git a/benefits/core/migrations/0002_paymentprocessor_audience_paymentprocessor_client_id_and_more.py b/benefits/core/migrations/0002_paymentprocessor_audience_paymentprocessor_client_id_and_more.py deleted file mode 100644 index 77754b1e7..000000000 --- a/benefits/core/migrations/0002_paymentprocessor_audience_paymentprocessor_client_id_and_more.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 5.0.2 on 2024-02-28 23:16 - -import benefits.core.models -import benefits.secrets -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="paymentprocessor", - name="audience", - field=models.TextField(default="audience"), - preserve_default=False, - ), - migrations.AddField( - model_name="paymentprocessor", - name="client_id", - field=models.TextField(default="client_id"), - preserve_default=False, - ), - migrations.AddField( - model_name="paymentprocessor", - name="client_secret_name", - field=benefits.core.models.SecretNameField( - default="client_secret_name", max_length=127, validators=[benefits.secrets.SecretNameValidator()] - ), - preserve_default=False, - ), - ] diff --git a/benefits/core/migrations/0003_remove_paymentprocessor_api_access_token_endpoint_and_more.py b/benefits/core/migrations/0002_paymentprocessor_backoffice_api.py similarity index 57% rename from benefits/core/migrations/0003_remove_paymentprocessor_api_access_token_endpoint_and_more.py rename to benefits/core/migrations/0002_paymentprocessor_backoffice_api.py index 45ef8893c..3d00748a5 100644 --- a/benefits/core/migrations/0003_remove_paymentprocessor_api_access_token_endpoint_and_more.py +++ b/benefits/core/migrations/0002_paymentprocessor_backoffice_api.py @@ -1,12 +1,14 @@ -# Generated by Django 5.0.2 on 2024-02-28 23:18 +# Generated by Django 5.0.2 on 2024-03-07 21:38 -from django.db import migrations +import benefits.core.models +import benefits.secrets +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("core", "0002_paymentprocessor_audience_paymentprocessor_client_id_and_more"), + ("core", "0001_initial"), ] operations = [ @@ -46,4 +48,24 @@ class Migration(migrations.Migration): model_name="paymentprocessor", name="group_endpoint", ), + migrations.AddField( + model_name="paymentprocessor", + name="audience", + field=models.TextField(default="audience"), + preserve_default=False, + ), + migrations.AddField( + model_name="paymentprocessor", + name="client_id", + field=models.TextField(default="client_id"), + preserve_default=False, + ), + migrations.AddField( + model_name="paymentprocessor", + name="client_secret_name", + field=benefits.core.models.SecretNameField( + default="client-secret-name", max_length=127, validators=[benefits.secrets.SecretNameValidator()] + ), + preserve_default=False, + ), ] From 3bfb9e86aee4ee8c0ab674083c2275aa82fc445c Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 7 Mar 2024 23:21:16 +0000 Subject: [PATCH 12/12] chore: clean up / update sample .env and update sample fixtures --- .env.sample | 12 +++--------- benefits/core/migrations/local_fixtures.json | 6 +++--- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/.env.sample b/.env.sample index b4c167aee..4ea4af189 100644 --- a/.env.sample +++ b/.env.sample @@ -12,14 +12,8 @@ courtesy_card_verifier_api_auth_key=server-auth-token mobility_pass_verifier_api_auth_key=server-auth-token client_private_key='-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA1pt0ZoOuPEVPJJS+5r884zcjZLkZZ2GcPwr79XOLDbOi46on\nCa79kjRnhS0VUK96SwUPS0z9J5mDA5LSNL2RoxFb5QGaevnJY828NupzTNdUd0sY\nJK3kRjKUggHWuB55hwJcH/Dx7I3DNH4NL68UAlK+VjwJkfYPrhq/bl5z8ZiurvBa\n5C1mDxhFpcTZlCfxQoas7D1d+uPACF6mEMbQNd3RaIaSREO50NvNywXIIt/OmCiR\nqI7JtOcn4eyh1I4j9WtlbMhRJLfwPMAgY5epTsWcURmhVofF2wVoFbib3JGCfA7t\nz/gmP5YoEKnf/cumKmF3e9LrZb8zwm7bTHUViwIDAQABAoIBAQCIv0XMjNvZS9DC\nXoXGQtVpcxj6dXfaiDgnc7hZDubsNCr3JtT5NqgdIYdVNQUABNDIPNEiCkzFjuwM\nuuF2+dRzM/x6UCs/cSsCjXYBCCOwMwV/fjpEJQnwMQqwTLulVsXZYYeSUtXVBf/8\n0tVULRty34apLFhsyX30UtboXQdESfpmm5ZsqsZJlYljw+M7JxRMneQclI19y/ya\nhPWlfhLB9OffVEJXGaWx1NSYnKoCMKqE/+4krROr6V62xXaNyX6WtU6XiT7C6R5A\nPBxfhmoeFdVCF6a+Qq0v2fKThYoZnV4sn2q2An9YPfynFYnlgzdfnAFSejsqxQd0\nfxYLOtMBAoGBAP1jxjHDJngZ1N+ymw9MIpRgr3HeuMP5phiSTbY2tu9lPzQd+TMX\nfhr1bQh2Fd/vU0u7X0yPnTWtUrLlCdGnWPpXivx95GNGgUUIk2HStFdrRx+f2Qvk\nG8vtLgmSbjQ26UiHzxi9Wa0a41PWIA3TixkcFrS2X29Qc4yd6pVHmicfAoGBANjR\nZ8aaDkSKLkq5Nk1T7I0E1+mtPoH1tPV/FJClXjJrvfDuYHBeOyUpipZddnZuPGWA\nIW2tFIsMgJQtgpvgs52NFI7pQGJRUPK/fTG+Ycocxo78TkLr/RIj8Kj5brXsbZ9P\n3/WBX5GAISTSp1ab8xVgK/Tm07hGupKVqnY2lCAVAoGAIql0YjhE2ecGtLcU+Qm8\nLTnwpg4GjmBnNTNGSCfB7IuYEsQK489R49Qw3xhwM5rkdRajmbCHm+Eiz+/+4NwY\nkt5I1/NMu7vYUR40MwyEuPSm3Q+bvEGu/71pL8wFIUVlshNJ5CN60fA8qqo+5kVK\n4Ntzy7Kq6WpC9Dhh75vE3ZcCgYEAty99uXtxsJD6+aEwcvcENkUwUztPQ6ggAwci\nje9Z/cmwCj6s9mN3HzfQ4qgGrZsHpk4ycCK655xhilBFOIQJ3YRUKUaDYk4H0YDe\nOsf6gTP8wtQDH2GZSNlavLk5w7UFDYQD2b47y4fw+NaOEYvjPl0p5lmb6ebAPZb8\nFbKZRd0CgYBC1HTbA+zMEqDdY4MWJJLC6jZsjdxOGhzjrCtWcIWEGMDF7oDDEoix\nW3j2hwm4C6vaNkH9XX1dr5+q6gq8vJQdbYoExl22BGMiNbfI3+sLRk0zBYL//W6c\ntSREgR4EjosqQfbkceLJ2JT1wuNjInI0eR9H3cRugvlDTeWtbdJ5qA==\n-----END RSA PRIVATE KEY-----' client_public_key='-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1pt0ZoOuPEVPJJS+5r88\n4zcjZLkZZ2GcPwr79XOLDbOi46onCa79kjRnhS0VUK96SwUPS0z9J5mDA5LSNL2R\noxFb5QGaevnJY828NupzTNdUd0sYJK3kRjKUggHWuB55hwJcH/Dx7I3DNH4NL68U\nAlK+VjwJkfYPrhq/bl5z8ZiurvBa5C1mDxhFpcTZlCfxQoas7D1d+uPACF6mEMbQ\nNd3RaIaSREO50NvNywXIIt/OmCiRqI7JtOcn4eyh1I4j9WtlbMhRJLfwPMAgY5ep\nTsWcURmhVofF2wVoFbib3JGCfA7tz/gmP5YoEKnf/cumKmF3e9LrZb8zwm7bTHUV\niwIDAQAB\n-----END PUBLIC KEY-----' -mst_payment_processor_client_cert='-----BEGIN CERTIFICATE-----\nPEM DATA\n-----END CERTIFICATE-----' -mst_payment_processor_client_cert_private_key='-----BEGIN RSA PRIVATE KEY-----\nPEM DATA\n-----END RSA PRIVATE KEY-----' -mst_payment_processor_client_cert_root_ca='-----BEGIN CERTIFICATE-----\nPEM DATA\n-----END CERTIFICATE-----' -sacrt_payment_processor_client_cert='-----BEGIN CERTIFICATE-----\nPEM DATA\n-----END CERTIFICATE-----' -sacrt_payment_processor_client_cert_private_key='-----BEGIN RSA PRIVATE KEY-----\nPEM DATA\n-----END RSA PRIVATE KEY-----' -sacrt_payment_processor_client_cert_root_ca='-----BEGIN CERTIFICATE-----\nPEM DATA\n-----END CERTIFICATE-----' -sbmtd_payment_processor_client_cert='-----BEGIN CERTIFICATE-----\nPEM DATA\n-----END CERTIFICATE-----' -sbmtd_payment_processor_client_cert_private_key='-----BEGIN RSA PRIVATE KEY-----\nPEM DATA\n-----END RSA PRIVATE KEY-----' -sbmtd_payment_processor_client_cert_root_ca='-----BEGIN CERTIFICATE-----\nPEM DATA\n-----END CERTIFICATE-----' +mst_payment_processor_client_secret=secret +sacrt_payment_processor_client_secret=secret +sbmtd_payment_processor_client_secret=secret testsecret="Hello from the local environment!" diff --git a/benefits/core/migrations/local_fixtures.json b/benefits/core/migrations/local_fixtures.json index 54ea2faa9..1e51dad7a 100644 --- a/benefits/core/migrations/local_fixtures.json +++ b/benefits/core/migrations/local_fixtures.json @@ -244,7 +244,7 @@ "name": "(MST) test payment processor", "api_base_url": "http://server:8000", "client_id": "", - "client_secret_name": "", + "client_secret_name": "mst-payment-processor-client-secret", "audience": "", "card_tokenize_url": "http://server:8000/static/tokenize.js", "card_tokenize_func": "tokenize", @@ -258,7 +258,7 @@ "name": "(SacRT) test payment processor", "api_base_url": "http://server:8000", "client_id": "", - "client_secret_name": "", + "client_secret_name": "sacrt-payment-processor-client-secret", "audience": "", "card_tokenize_url": "http://server:8000/static/tokenize.js", "card_tokenize_func": "tokenize", @@ -272,7 +272,7 @@ "name": "(SBMTD) test payment processor", "api_base_url": "http://server:8000", "client_id": "", - "client_secret_name": "", + "client_secret_name": "sbmtd-payment-processor-client-secret", "audience": "", "card_tokenize_url": "http://server:8000/static/tokenize.js", "card_tokenize_func": "tokenize",