Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor: enrollment token logic #2348

Merged
merged 4 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions benefits/enrollment/enrollment.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from enum import Enum
from dataclasses import dataclass
from datetime import timedelta
from enum import Enum

from django.utils import timezone
from littlepay.api.client import Client
Expand All @@ -23,7 +24,54 @@ class Status(Enum):
REENROLLMENT_ERROR = 4


def enroll(request, card_token):
@dataclass
class CardTokenizationAccessResponse:
status: Status
access_token: str
expires_at: str
exception: Exception = None
status_code: int = None


def request_card_tokenization_access(request) -> CardTokenizationAccessResponse:
"""
Requests an access token to be used for card tokenization.
"""
agency = session.agency(request)

try:
client = Client(
base_url=agency.transit_processor.api_base_url,
client_id=agency.transit_processor_client_id,
client_secret=agency.transit_processor_client_secret,
audience=agency.transit_processor_audience,
)
client.oauth.ensure_active_token(client.token)
response = client.request_card_tokenization_access()

return CardTokenizationAccessResponse(
status=Status.SUCCESS, access_token=response.get("access_token"), expires_at=response.get("expires_at")
)
except Exception as e:
exception = e

if isinstance(e, HTTPError):
status_code = e.response.status_code

if status_code >= 500:
status = Status.SYSTEM_ERROR
else:
status = Status.EXCEPTION
else:
status_code = None
status = Status.EXCEPTION

return CardTokenizationAccessResponse(
status=status, access_token=None, expires_at=None, exception=exception, status_code=status_code
)


def enroll(request, card_token) -> tuple[Status, Exception]:
"""
Attempts to enroll this card into the transit processor group for the flow in the request's session.

Expand Down
42 changes: 11 additions & 31 deletions benefits/enrollment/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,14 @@
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
import sentry_sdk

from benefits.routes import routes
from benefits.core import session
from benefits.core.middleware import EligibleSessionRequired, FlowSessionRequired, pageview_decorator

from . import analytics, forms
from .enrollment import Status, enroll
from .enrollment import Status, request_card_tokenization_access, enroll

TEMPLATE_RETRY = "enrollment/retry.html"
TEMPLATE_SYSTEM_ERROR = "enrollment/system_error.html"
Expand All @@ -31,40 +29,22 @@
def token(request):
"""View handler for the enrollment auth token."""
if not session.enrollment_token_valid(request):
agency = session.agency(request)
response = request_card_tokenization_access(request)

if response.status is Status.SUCCESS:
session.update(request, enrollment_token=response.access_token, enrollment_token_exp=response.expires_at)
elif response.status is Status.SYSTEM_ERROR or response.status is Status.EXCEPTION:
logger.debug("Error occurred while requesting access token", exc_info=response.exception)
sentry_sdk.capture_exception(response.exception)
analytics.failed_access_token_request(request, response.status_code)

try:
client = Client(
base_url=agency.transit_processor.api_base_url,
client_id=agency.transit_processor_client_id,
client_secret=agency.transit_processor_client_secret,
audience=agency.transit_processor_audience,
)
client.oauth.ensure_active_token(client.token)
response = client.request_card_tokenization_access()
except Exception as e:
logger.debug("Error occurred while requesting access token", exc_info=e)
sentry_sdk.capture_exception(e)

if isinstance(e, HTTPError):
status_code = e.response.status_code

if status_code >= 500:
redirect = reverse(routes.ENROLLMENT_SYSTEM_ERROR)
else:
redirect = reverse(routes.SERVER_ERROR)
if response.status is Status.SYSTEM_ERROR:
redirect = reverse(routes.ENROLLMENT_SYSTEM_ERROR)
else:
status_code = None
redirect = reverse(routes.SERVER_ERROR)

analytics.failed_access_token_request(request, status_code)

data = {"redirect": redirect}
return JsonResponse(data)
else:
session.update(
request, enrollment_token=response.get("access_token"), enrollment_token_exp=response.get("expires_at")
)

data = {"token": session.enrollment_token(request)}

Expand Down
84 changes: 84 additions & 0 deletions tests/pytest/enrollment/test_enrollment.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from benefits.enrollment.enrollment import (
Status,
request_card_tokenization_access,
enroll,
_get_group_funding_source,
_calculate_expiry,
Expand Down Expand Up @@ -510,3 +511,86 @@ def test_enroll_does_not_support_expiration_has_expiration_date(
# group_id=model_EnrollmentFlow_does_not_support_expiration.group_id,
# expiry_date=None,
# )


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency")
def test_request_card_tokenization_access(mocker, app_request):
mock_response = {}
mock_response["access_token"] = "123"
mock_response["expires_at"] = "2024-01-01T00:00:00"

mock_client_cls = mocker.patch("benefits.enrollment.enrollment.Client")
mock_client = mock_client_cls.return_value
mock_client.request_card_tokenization_access.return_value = mock_response

response = request_card_tokenization_access(app_request)

assert response.status is Status.SUCCESS
assert response.access_token == "123"
assert response.expires_at == "2024-01-01T00:00:00"
assert response.exception is None
assert response.status_code is None
mock_client.oauth.ensure_active_token.assert_called_once()


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency")
def test_request_card_tokenization_access_system_error(mocker, app_request):
mock_client_cls = mocker.patch("benefits.enrollment.enrollment.Client")
mock_client = mock_client_cls.return_value

mock_error = {"message": "Mock error message"}
mock_error_response = mocker.Mock(status_code=500, **mock_error)
mock_error_response.json.return_value = mock_error
http_error = HTTPError(response=mock_error_response)

mock_client.request_card_tokenization_access.side_effect = http_error

response = request_card_tokenization_access(app_request)

assert response.status is Status.SYSTEM_ERROR
assert response.access_token is None
assert response.expires_at is None
assert response.exception == http_error
assert response.status_code == 500


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency")
def test_request_card_tokenization_access_http_400_error(mocker, app_request):
mock_client_cls = mocker.patch("benefits.enrollment.enrollment.Client")
mock_client = mock_client_cls.return_value

mock_error = {"message": "Mock error message"}
mock_error_response = mocker.Mock(status_code=400, **mock_error)
mock_error_response.json.return_value = mock_error
http_error = HTTPError(response=mock_error_response)

mock_client.request_card_tokenization_access.side_effect = http_error

response = request_card_tokenization_access(app_request)

assert response.status is Status.EXCEPTION
assert response.access_token is None
assert response.expires_at is None
assert response.exception == http_error
assert response.status_code == 400


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency")
def test_request_card_tokenization_access_exception(mocker, app_request):
mock_client_cls = mocker.patch("benefits.enrollment.enrollment.Client")
mock_client = mock_client_cls.return_value

exception = Exception("some exception")
mock_client.request_card_tokenization_access.side_effect = exception

response = request_card_tokenization_access(app_request)

assert response.status is Status.EXCEPTION
assert response.access_token is None
assert response.expires_at is None
assert response.exception == exception
assert response.status_code is None
61 changes: 39 additions & 22 deletions tests/pytest/enrollment/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from benefits.routes import routes
import benefits.enrollment.views
from benefits.enrollment.enrollment import Status
from benefits.enrollment.enrollment import Status, CardTokenizationAccessResponse
from benefits.core.middleware import TEMPLATE_USER_ERROR
from benefits.enrollment.views import TEMPLATE_SYSTEM_ERROR, TEMPLATE_RETRY

Expand Down Expand Up @@ -47,12 +47,18 @@ def test_token_ineligible(client):
def test_token_refresh(mocker, client):
mocker.patch("benefits.core.session.enrollment_token_valid", return_value=False)

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

mocker.patch(
"benefits.enrollment.views.request_card_tokenization_access",
return_value=CardTokenizationAccessResponse(
Status.SUCCESS,
access_token=mock_token["access_token"],
expires_at=mock_token["expires_at"],
),
)

path = reverse(routes.ENROLLMENT_TOKEN)
response = client.get(path)
Expand All @@ -61,7 +67,6 @@ 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
Expand All @@ -81,17 +86,19 @@ def test_token_valid(mocker, client):

@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligible")
def test_token_http_error_500(mocker, client, mocked_analytics_module, mocked_sentry_sdk_module):
def test_token_system_error(mocker, client, mocked_analytics_module, mocked_sentry_sdk_module):
mocker.patch("benefits.core.session.enrollment_token_valid", return_value=False)

mock_client_cls = mocker.patch("benefits.enrollment.views.Client")
mock_client = mock_client_cls.return_value

mock_error = {"message": "Mock error message"}
mock_error_response = mocker.Mock(status_code=500, **mock_error)
mock_error_response.json.return_value = mock_error
mock_client.request_card_tokenization_access.side_effect = HTTPError(
response=mock_error_response,
http_error = HTTPError(response=mock_error_response)

mocker.patch(
"benefits.enrollment.views.request_card_tokenization_access",
return_value=CardTokenizationAccessResponse(
Status.SYSTEM_ERROR, access_token=None, expires_at=None, exception=http_error, status_code=500
),
)

path = reverse(routes.ENROLLMENT_TOKEN)
Expand All @@ -112,14 +119,16 @@ def test_token_http_error_500(mocker, client, mocked_analytics_module, mocked_se
def test_token_http_error_400(mocker, client, mocked_analytics_module, mocked_sentry_sdk_module):
mocker.patch("benefits.core.session.enrollment_token_valid", return_value=False)

mock_client_cls = mocker.patch("benefits.enrollment.views.Client")
mock_client = mock_client_cls.return_value

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.request_card_tokenization_access.side_effect = HTTPError(
response=mock_error_response,
http_error = HTTPError(response=mock_error_response)

mocker.patch(
"benefits.enrollment.views.request_card_tokenization_access",
return_value=CardTokenizationAccessResponse(
Status.EXCEPTION, access_token=None, expires_at=None, exception=http_error, status_code=400
),
)

path = reverse(routes.ENROLLMENT_TOKEN)
Expand All @@ -140,10 +149,14 @@ def test_token_http_error_400(mocker, client, mocked_analytics_module, mocked_se
def test_token_misconfigured_client_id(mocker, client, mocked_analytics_module, mocked_sentry_sdk_module):
mocker.patch("benefits.core.session.enrollment_token_valid", return_value=False)

mock_client_cls = mocker.patch("benefits.enrollment.views.Client")
mock_client = mock_client_cls.return_value
exception = UnsupportedTokenTypeError()

mock_client.request_card_tokenization_access.side_effect = UnsupportedTokenTypeError()
mocker.patch(
"benefits.enrollment.views.request_card_tokenization_access",
return_value=CardTokenizationAccessResponse(
Status.EXCEPTION, access_token=None, expires_at=None, exception=exception, status_code=None
),
)

path = reverse(routes.ENROLLMENT_TOKEN)
response = client.get(path)
Expand All @@ -162,10 +175,14 @@ def test_token_misconfigured_client_id(mocker, client, mocked_analytics_module,
def test_token_connection_error(mocker, client, mocked_analytics_module, mocked_sentry_sdk_module):
mocker.patch("benefits.core.session.enrollment_token_valid", return_value=False)

mock_client_cls = mocker.patch("benefits.enrollment.views.Client")
mock_client = mock_client_cls.return_value
exception = ConnectionError()

mock_client.oauth.ensure_active_token.side_effect = ConnectionError()
mocker.patch(
"benefits.enrollment.views.request_card_tokenization_access",
return_value=CardTokenizationAccessResponse(
Status.EXCEPTION, access_token=None, expires_at=None, exception=exception, status_code=None
),
)

path = reverse(routes.ENROLLMENT_TOKEN)
response = client.get(path)
Expand Down
Loading