From 9f4863573d6ac859abfbbea7e874209e9bd10486 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Wed, 1 May 2024 23:13:22 +0000 Subject: [PATCH 1/4] refactor: show system enrollment error page if 500 during /token --- .../templates/enrollment/index.html | 6 +++++ benefits/enrollment/urls.py | 1 + benefits/enrollment/views.py | 17 ++++++++++-- tests/pytest/enrollment/test_views.py | 26 +++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/benefits/enrollment/templates/enrollment/index.html b/benefits/enrollment/templates/enrollment/index.html index f75e42303..0a4d8c318 100644 --- a/benefits/enrollment/templates/enrollment/index.html +++ b/benefits/enrollment/templates/enrollment/index.html @@ -41,6 +41,12 @@

$.ajax({ dataType: "script", attrs: { nonce: "{{ request.csp_nonce }}"}, url: "{{ card_tokenize_url }}" }) .done(function() { $.get("{{ access_token_url }}", function(data) { + if (data.redirect) { + // https://stackoverflow.com/a/42469170 + // use 'assign' because 'replace' was giving strange Back button behavior + window.location.assign(data.redirect); + } + $(".loading").remove(); // remove invisible and add back visible, so we aren't left with // a div with an empty class attribute diff --git a/benefits/enrollment/urls.py b/benefits/enrollment/urls.py index f74eb2452..fb3ddd5c1 100644 --- a/benefits/enrollment/urls.py +++ b/benefits/enrollment/urls.py @@ -15,4 +15,5 @@ path("reenrollment-error", views.reenrollment_error, name="reenrollment-error"), path("retry", views.retry, name="retry"), path("success", views.success, name="success"), + path("error", views.system_error, name="system-error"), ] diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index ee81973ab..7592abeae 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -22,6 +22,7 @@ ROUTE_REENROLLMENT_ERROR = "enrollment:reenrollment-error" ROUTE_RETRY = "enrollment:retry" ROUTE_SUCCESS = "enrollment:success" +ROUTE_SYSTEM_ERROR = "enrollment:system-error" ROUTE_TOKEN = "enrollment:token" TEMPLATE_RETRY = "enrollment/retry.html" @@ -44,8 +45,20 @@ def token(request): 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")) + + try: + response = client.request_card_tokenization_access() + except Exception as e: + if isinstance(e, HTTPError) and e.response.status_code >= 500: + sentry_sdk.capture_exception(e) + data = {"redirect": reverse(ROUTE_SYSTEM_ERROR)} + return JsonResponse(data) + else: + raise e + else: + 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/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 49e25c708..231ba31fa 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -13,6 +13,7 @@ ROUTE_REENROLLMENT_ERROR, ROUTE_RETRY, ROUTE_SUCCESS, + ROUTE_SYSTEM_ERROR, ROUTE_TOKEN, TEMPLATE_SYSTEM_ERROR, TEMPLATE_RETRY, @@ -102,6 +103,31 @@ def test_token_valid(mocker, client): assert data["token"] == "enrollment_token" +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility") +def test_token_http_error_500(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_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, + ) + + path = reverse(ROUTE_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(ROUTE_SYSTEM_ERROR) + + @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") def test_index_eligible_get(client, model_EligibilityType): From 3d90770d59fbb39adf3388f866a884e46c1556f9 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Tue, 21 May 2024 20:21:44 +0000 Subject: [PATCH 2/4] feat(analytics): send analytic event when 500 error during /token --- benefits/enrollment/analytics.py | 14 ++++++++++++++ benefits/enrollment/views.py | 1 + tests/pytest/enrollment/test_analytics.py | 10 ++++++++++ tests/pytest/enrollment/test_views.py | 4 +++- 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 tests/pytest/enrollment/test_analytics.py diff --git a/benefits/enrollment/analytics.py b/benefits/enrollment/analytics.py index 3fc60b4ab..b92e4d349 100644 --- a/benefits/enrollment/analytics.py +++ b/benefits/enrollment/analytics.py @@ -16,6 +16,15 @@ def __init__(self, request, status, error=None, payment_group=None): self.update_event_properties(payment_group=payment_group) +class FailedAccessTokenRequestEvent(core.Event): + """Analytics event representing a failure to acquire an access token for card tokenization.""" + + def __init__(self, request, status_code=None): + super().__init__(request, "failed access token request") + if status_code is not None: + self.update_event_properties(status_code=status_code) + + def returned_error(request, error): """Send the "returned enrollment" analytics event with an error status and message.""" core.send_event(ReturnedEnrollmentEvent(request, status="error", error=error)) @@ -29,3 +38,8 @@ def returned_retry(request): def returned_success(request, payment_group): """Send the "returned enrollment" analytics event with a success status.""" core.send_event(ReturnedEnrollmentEvent(request, status="success", payment_group=payment_group)) + + +def failed_access_token_request(request, status_code=None): + """Send the "failed access token request" analytics event with the response status code.""" + core.send_event(FailedAccessTokenRequestEvent(request, status_code=status_code)) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index 7592abeae..5211f5653 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -50,6 +50,7 @@ def token(request): response = client.request_card_tokenization_access() except Exception as e: if isinstance(e, HTTPError) and e.response.status_code >= 500: + analytics.failed_access_token_request(request, e.response.status_code) sentry_sdk.capture_exception(e) data = {"redirect": reverse(ROUTE_SYSTEM_ERROR)} return JsonResponse(data) diff --git a/tests/pytest/enrollment/test_analytics.py b/tests/pytest/enrollment/test_analytics.py new file mode 100644 index 000000000..d774586a9 --- /dev/null +++ b/tests/pytest/enrollment/test_analytics.py @@ -0,0 +1,10 @@ +import pytest + +from benefits.enrollment.analytics import FailedAccessTokenRequestEvent + + +@pytest.mark.django_db +def test_FailedAccessTokenRequestEvent_sets_status_code(app_request): + event = FailedAccessTokenRequestEvent(app_request, 500) + + assert event.event_properties["status_code"] == 500 diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 231ba31fa..a5c93f366 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -105,7 +105,7 @@ def test_token_valid(mocker, client): @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility") -def test_token_http_error_500(mocker, client): +def test_token_http_error_500(mocker, client, mocked_analytics_module): mocker.patch("benefits.core.session.enrollment_token_valid", return_value=False) mock_client_cls = mocker.patch("benefits.enrollment.views.Client") @@ -126,6 +126,8 @@ def test_token_http_error_500(mocker, client): assert "token" not in data assert "redirect" in data assert data["redirect"] == reverse(ROUTE_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 @pytest.mark.django_db From 2953bb60f63fd923189e1f3bcd01999b66242b45 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Tue, 21 May 2024 20:35:18 +0000 Subject: [PATCH 3/4] test(sentry): add assertion for sentry method call --- tests/pytest/enrollment/test_views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index a5c93f366..da557956f 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -105,7 +105,7 @@ def test_token_valid(mocker, client): @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility") -def test_token_http_error_500(mocker, client, mocked_analytics_module): +def test_token_http_error_500(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") @@ -128,6 +128,7 @@ def test_token_http_error_500(mocker, client, mocked_analytics_module): assert data["redirect"] == reverse(ROUTE_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 From 3769a007b42af8e310d4008fd342bf119676829c Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Tue, 21 May 2024 22:36:44 +0000 Subject: [PATCH 4/4] refactor: send sentry notification if any exception occurs during /token --- benefits/enrollment/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index 5211f5653..9c59c5120 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -49,9 +49,10 @@ def token(request): try: response = client.request_card_tokenization_access() except Exception as e: + sentry_sdk.capture_exception(e) + if isinstance(e, HTTPError) and e.response.status_code >= 500: analytics.failed_access_token_request(request, e.response.status_code) - sentry_sdk.capture_exception(e) data = {"redirect": reverse(ROUTE_SYSTEM_ERROR)} return JsonResponse(data) else: