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/.github/dependabot.yml b/.github/dependabot.yml index 20ede63b0..96c61c651 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,15 +14,6 @@ updates: include: "scope" labels: - "dependencies" - - package-ecosystem: "pip" - directory: "/docs" # requirements.txt - schedule: - interval: "daily" - commit-message: - prefix: "chore" - include: "scope" - labels: - - "dependencies" - package-ecosystem: "github-actions" # Workflow files stored in the # default location of `.github/workflows` diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd5fe4de9..23b79b33b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,7 +48,7 @@ repos: - python - repo: https://github.com/pycqa/bandit - rev: 1.7.7 + rev: 1.7.8 hooks: - id: bandit args: ["-ll"] diff --git a/benefits/core/migrations/0002_paymentprocessor_backoffice_api.py b/benefits/core/migrations/0002_paymentprocessor_backoffice_api.py new file mode 100644 index 000000000..3d00748a5 --- /dev/null +++ b/benefits/core/migrations/0002_paymentprocessor_backoffice_api.py @@ -0,0 +1,71 @@ +# Generated by Django 5.0.2 on 2024-03-07 21:38 + +import benefits.core.models +import benefits.secrets +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0001_initial"), + ] + + 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", + ), + 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/local_fixtures.json b/benefits/core/migrations/local_fixtures.json index 0aaf9a808..1e51dad7a 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": "mst-payment-processor-client-secret", + "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": "sacrt-payment-processor-client-secret", + "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": "sbmtd-payment-processor-client-secret", + "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 8473fc2cc..72561c93a 100644 --- a/benefits/core/models.py +++ b/benefits/core/models.py @@ -206,21 +206,16 @@ 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): + return get_secret_by_name(self.client_secret_name) def __str__(self): return self.name 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/benefits/enrollment/views.py b/benefits/enrollment/views.py index ebac42b02..71ac35e16 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -8,6 +8,8 @@ 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 requests.exceptions import HTTPError from benefits.core import models, session from benefits.core.middleware import ( @@ -16,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" @@ -37,8 +39,16 @@ 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, + ) + 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")) data = {"token": session.enrollment_token(request)} @@ -51,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": @@ -64,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, str(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: diff --git a/benefits/sentry.py b/benefits/sentry.py index 86585e58b..af4603aa6 100644 --- a/benefits/sentry.py +++ b/benefits/sentry.py @@ -100,7 +100,7 @@ def configure(): # send_default_pii must be False (the default) for a custom EventScrubber/denylist # https://docs.sentry.io/platforms/python/data-management/sensitive-data/#event_scrubber send_default_pii=False, - event_scrubber=EventScrubber(denylist=get_denylist()), + event_scrubber=EventScrubber(denylist=get_denylist(), recursive=True), ) # override the module-level variable when configuration happens, if set diff --git a/docs/enrollment-pathways/agency-cards.md b/docs/enrollment-pathways/agency-cards.md index af586a457..709d35b4c 100644 --- a/docs/enrollment-pathways/agency-cards.md +++ b/docs/enrollment-pathways/agency-cards.md @@ -6,6 +6,12 @@ _Agency Cards_ is a generic term for reduced fare programs offered by Transit Pr Agency cards are different from our other use cases in that eligibility verification happens on the agency side (offline) rather than through the Benefits app, and the Benefits app then checks for a valid Agency Card via an [Eligibility API call](https://docs.calitp.org/eligibility-api/specification/). +## Demonstration + +Here's a video showing what the flow looks like, having agency cardholders confirm eligibility via the Eligibility Server and enroll via Littlepay: + + + ## Architecture In order to support an Agency Cards deployment, the Transit Provider produces a list of eligible users diff --git a/docs/enrollment-pathways/img/senior-success.gif b/docs/enrollment-pathways/img/senior-success.gif deleted file mode 100644 index 9c0fea79e..000000000 Binary files a/docs/enrollment-pathways/img/senior-success.gif and /dev/null differ diff --git a/docs/enrollment-pathways/older-adults.md b/docs/enrollment-pathways/older-adults.md index 6d5155fd6..775dde698 100644 --- a/docs/enrollment-pathways/older-adults.md +++ b/docs/enrollment-pathways/older-adults.md @@ -6,9 +6,9 @@ Currently, the app uses [Login.gov's Identity Assurance Level 2 (IAL2)](https:// ## Demonstration -Here's a GIF showing what the flow looks like, having older adults confirm eligibility via Login.gov and enroll via LittlePay: +Here's a video showing what the flow looks like, having older adults confirm eligibility via Login.gov and enroll via Littlepay: -![Demonstration of the sign-up process for a senior confirming eligibility via Login.gov and enrolling via Littlepay](img/senior-success.gif){ width="350" } + ## Process diff --git a/docs/enrollment-pathways/veterans.md b/docs/enrollment-pathways/veterans.md index 7c4af98a5..441d452db 100644 --- a/docs/enrollment-pathways/veterans.md +++ b/docs/enrollment-pathways/veterans.md @@ -10,6 +10,12 @@ This use case describes a feature in the [Cal-ITP Benefits app](https://benefits **Precondition:** The California transit provider delivering fixed route service has installed and tested validator hardware necessary to collect fares using contactless payment on bus or rail lines, and the provider has a policy to offer a transit discount for US veterans. +## Demonstration + +Here's a video showing what the flow looks like, having veterans confirm eligibility via Login.gov and enroll via Littlepay: + + + ## Basic flow ```mermaid diff --git a/docs/requirements.txt b/docs/requirements.txt index 0211c4936..c4a10f5d0 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,5 +2,5 @@ mdx_truly_sane_lists mkdocs==1.5.3 mkdocs-awesome-pages-plugin mkdocs-macros-plugin -mkdocs-material==9.5.12 +mkdocs-material==9.5.13 mkdocs-redirects diff --git a/pyproject.toml b/pyproject.toml index 18c61600b..33117ee78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "benefits" -version = "2024.03.1" +version = "2024.03.2" description = "Cal-ITP Benefits is an application that enables automated eligibility verification and enrollment for transit benefits onto customers’ existing contactless bank (credit/debit) cards." readme = "README.md" license = { file = "LICENSE" } @@ -10,12 +10,13 @@ dependencies = [ "Authlib==1.3.0", "azure-keyvault-secrets==4.8.0", "azure-identity==1.15.0", - "Django==5.0.2", + "Django==5.0.3", "django-csp==3.8", - "django-google-sso==5.0.0", + "django-google-sso==6.0.1", "eligibility-api==2023.9.1", + "calitp-littlepay==2024.3.1", "requests==2.31.0", - "sentry-sdk==1.40.6", + "sentry-sdk==1.41.0", "six==1.16.0", ] diff --git a/tests/pytest/conftest.py b/tests/pytest/conftest.py index cc62540bc..0dcf3aa8b 100644 --- a/tests/pytest/conftest.py +++ b/tests/pytest/conftest.py @@ -130,22 +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 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) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 44fb77376..84799d99f 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 @@ -34,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) @@ -49,11 +67,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.api.Client.access_token") - mock_token = mocker.Mock() - mock_token.access_token = "access_token" - mock_token.expiry = time.time() + 10000 - mock_client.return_value = mock_token + 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.request_card_tokenization_access.return_value = mock_token path = reverse(ROUTE_TOKEN) response = client.get(path) @@ -61,7 +80,8 @@ 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"] + mock_client.oauth.ensure_active_token.assert_called_once() @pytest.mark.django_db @@ -106,31 +126,76 @@ def test_index_eligible_post_invalid_form(client, invalid_form_data): client.post(path, invalid_form_data) +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility") +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 + + # 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_error["message"]): + 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_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 + + mock_client.link_concession_group_funding_source.side_effect = Exception("some other exception") path = reverse(ROUTE_INDEX) - with pytest.raises(Exception, match=mock_response.message): + 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( + 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 + 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 + ) + 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 + mocker, client, card_tokenize_form_data, mocked_analytics_module, model_EligibilityType, mocked_funding_source ): - 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_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=mocked_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()