Skip to content

Refactor: add enrollment_littlepay token view #2934

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

Merged
merged 4 commits into from
May 27, 2025
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
1 change: 0 additions & 1 deletion benefits/enrollment/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
urlpatterns = [
# /enrollment
path("", views.index, name=routes.name(routes.ENROLLMENT_INDEX)),
path("token", views.token, name=routes.name(routes.ENROLLMENT_TOKEN)),
path("error/reenrollment", views.reenrollment_error, name=routes.name(routes.ENROLLMENT_REENROLLMENT_ERROR)),
path("retry", views.retry, name=routes.name(routes.ENROLLMENT_RETRY)),
path("success", views.success, name=routes.name(routes.ENROLLMENT_SUCCESS)),
Expand Down
32 changes: 0 additions & 32 deletions benefits/enrollment/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import logging


from django.http import JsonResponse
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.decorators import decorator_from_middleware
Expand All @@ -15,8 +14,6 @@
from benefits.core import models, session
from benefits.core.middleware import EligibleSessionRequired, FlowSessionRequired, pageview_decorator

from benefits.enrollment_littlepay.enrollment import request_card_tokenization_access
from benefits.enrollment_littlepay.session import Session as LittlepaySession
from benefits.enrollment_littlepay.views import IndexView as LittlepayIndexView
from . import analytics
from .enrollment import Status
Expand All @@ -28,35 +25,6 @@
logger = logging.getLogger(__name__)


@decorator_from_middleware(EligibleSessionRequired)
def token(request):
"""View handler for the enrollment auth token."""
session = LittlepaySession(request)

if not session.access_token_valid():
response = request_card_tokenization_access(request)

if response.status is Status.SUCCESS:
session.access_token = response.access_token
session.access_token_expiry = 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)

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

data = {"redirect": redirect}
return JsonResponse(data)

data = {"token": session.access_token}

return JsonResponse(data)


@decorator_from_middleware(EligibleSessionRequired)
def index(request):
"""View handler for the enrollment landing page."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

$.ajax({ dataType: "script", attrs: { nonce: "{{ request.csp_nonce }}"}, url: "{{ transit_processor.card_tokenize_url }}" })
.done(function() {
$.get("{% url routes.ENROLLMENT_TOKEN %}", function(data) {
$.get("{% url routes.ENROLLMENT_LITTLEPAY_TOKEN %}", function(data) {
if (data.redirect) {
// https://stackoverflow.com/a/42469170
// use 'assign' because 'replace' was giving strange Back button behavior
Expand Down
11 changes: 11 additions & 0 deletions benefits/enrollment_littlepay/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.urls import path

from benefits.routes import routes
from benefits.enrollment_littlepay.views import TokenView


app_name = "littlepay"
urlpatterns = [
# /littlepay
path("token", TokenView.as_view(), name=routes.name(routes.ENROLLMENT_LITTLEPAY_TOKEN)),
]
49 changes: 43 additions & 6 deletions benefits/enrollment_littlepay/views.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,49 @@
from django.views.generic import FormView
import logging

from benefits.routes import routes
from benefits.core import session
from django.http import JsonResponse
from django.urls import reverse
from django.views.generic import FormView, View
import sentry_sdk

from benefits.core import models
from benefits.routes import routes
from benefits.core import models, session
from benefits.core.mixins import EligibleSessionRequiredMixin
from benefits.enrollment import forms
from benefits.enrollment_littlepay.enrollment import enroll

from benefits.enrollment import analytics, forms
from benefits.enrollment.enrollment import Status
from benefits.enrollment_littlepay.enrollment import enroll, request_card_tokenization_access
from benefits.enrollment_littlepay.session import Session

logger = logging.getLogger(__name__)


class TokenView(EligibleSessionRequiredMixin, View):
"""View handler for the card tokenization access token."""

def get(self, request, *args, **kwargs):
session = Session(request)

if not session.access_token_valid():
response = request_card_tokenization_access(request)

if response.status is Status.SUCCESS:
session.access_token = response.access_token
session.access_token_expiry = 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)

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

data = {"redirect": redirect}
return JsonResponse(data)

data = {"token": session.access_token}
return JsonResponse(data)


class IndexView(EligibleSessionRequiredMixin, FormView):
Expand Down
6 changes: 3 additions & 3 deletions benefits/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@ def ENROLLMENT_INDEX(self):
return "enrollment:index"

@property
def ENROLLMENT_TOKEN(self):
"""Acquire a TransitProcessor API token for enrollment."""
return "enrollment:token"
def ENROLLMENT_LITTLEPAY_TOKEN(self):
"""Acquire a Littlepay card tokenization access token for enrollment."""
return "littlepay:token"

@property
def ENROLLMENT_SUCCESS(self):
Expand Down
1 change: 1 addition & 0 deletions benefits/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
path("i18n/", include("django.conf.urls.i18n")),
path("oauth/", include("benefits.oauth.urls")),
path("in_person/", include("benefits.in_person.urls")),
path("littlepay/", include("benefits.enrollment_littlepay.urls")),
]

if settings.RUNTIME_ENVIRONMENT() == settings.RUNTIME_ENVS.LOCAL:
Expand Down
169 changes: 0 additions & 169 deletions tests/pytest/enrollment/test_views.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import time

import pytest
from authlib.integrations.base_client.errors import UnsupportedTokenTypeError
from django.urls import reverse
from requests import HTTPError
from unittest.mock import patch, PropertyMock

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

Expand All @@ -35,170 +30,6 @@ def mocked_sentry_sdk_module(mocker):
return mocker.patch.object(benefits.enrollment.views, "sentry_sdk")


@pytest.mark.django_db
def test_token_ineligible(client):
path = reverse(routes.ENROLLMENT_TOKEN)

response = client.get(path)

assert response.status_code == 200
assert response.template_name == TEMPLATE_USER_ERROR


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligible")
def test_token_refresh(mocker, client):
mocker.patch("benefits.enrollment_littlepay.session.Session.access_token_valid", return_value=False)

mock_token = {}
mock_token["access_token"] = "access_token"
mock_token["expires_at"] = time.time() + 10000

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)

assert response.status_code == 200
data = response.json()
assert "token" in data
assert data["token"] == mock_token["access_token"]


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligible")
@patch("benefits.enrollment_littlepay.session.Session.access_token", new=PropertyMock(return_value="enrollment_token"))
def test_token_valid(mocker, client):
mocker.patch("benefits.enrollment_littlepay.session.Session.access_token_valid", return_value=True)

path = reverse(routes.ENROLLMENT_TOKEN)
response = client.get(path)

assert response.status_code == 200
data = response.json()
assert "token" in data
assert data["token"] == "enrollment_token"


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

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)

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)
response = client.get(path)

assert response.status_code == 200
data = response.json()
assert "token" not in data
assert "redirect" in data
assert data["redirect"] == reverse(routes.ENROLLMENT_SYSTEM_ERROR)
mocked_analytics_module.failed_access_token_request.assert_called_once()
assert 500 in mocked_analytics_module.failed_access_token_request.call_args.args
mocked_sentry_sdk_module.capture_exception.assert_called_once()


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligible")
def test_token_http_error_400(mocker, client, mocked_analytics_module, mocked_sentry_sdk_module):
mocker.patch("benefits.enrollment_littlepay.session.Session.access_token_valid", return_value=False)

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)

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)
response = client.get(path)

assert response.status_code == 200
data = response.json()
assert "token" not in data
assert "redirect" in data
assert data["redirect"] == reverse(routes.SERVER_ERROR)
mocked_analytics_module.failed_access_token_request.assert_called_once()
assert 400 in mocked_analytics_module.failed_access_token_request.call_args.args
mocked_sentry_sdk_module.capture_exception.assert_called_once()


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligible")
def test_token_misconfigured_client_id(mocker, client, mocked_analytics_module, mocked_sentry_sdk_module):
mocker.patch("benefits.enrollment_littlepay.session.Session.access_token_valid", return_value=False)

exception = 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)

assert response.status_code == 200
data = response.json()
assert "token" not in data
assert "redirect" in data
assert data["redirect"] == reverse(routes.SERVER_ERROR)
mocked_analytics_module.failed_access_token_request.assert_called_once()
mocked_sentry_sdk_module.capture_exception.assert_called_once()


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligible")
def test_token_connection_error(mocker, client, mocked_analytics_module, mocked_sentry_sdk_module):
mocker.patch("benefits.enrollment_littlepay.session.Session.access_token_valid", return_value=False)

exception = 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)

assert response.status_code == 200
data = response.json()
assert "token" not in data
assert "redirect" in data
assert data["redirect"] == reverse(routes.SERVER_ERROR)
mocked_analytics_module.failed_access_token_request.assert_called_once()
mocked_sentry_sdk_module.capture_exception.assert_called_once()


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_flow", "mocked_session_eligible")
def test_index_eligible_get(client):
Expand Down
Loading
Loading