Skip to content
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
3 changes: 1 addition & 2 deletions benefits/core/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@
from django.utils.deprecation import MiddlewareMixin
from django.views import i18n

from benefits.core import analytics, recaptcha, session
from benefits.routes import routes

from . import analytics, recaptcha, session

logger = logging.getLogger(__name__)

HEALTHCHECK_PATH = "/healthcheck"
Expand Down
29 changes: 19 additions & 10 deletions benefits/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,18 @@

from django.urls import path, register_converter

from benefits.core import models
from benefits.core.views import (
AgencyCardView,
AgencyEligibilityIndexView,
AgencyIndexView,
AgencyPublicKeyView,
HelpView,
IndexView,
LoggedOutView,
)
from benefits.routes import routes

from . import models, views
from benefits.views import ServerErrorView

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -46,16 +55,16 @@ def to_url(self, agency):
app_name = "core"

urlpatterns = [
path("", views.IndexView.as_view(), name=routes.name(routes.INDEX)),
path("help", views.HelpView.as_view(), name=routes.name(routes.HELP)),
path("logged_out", views.LoggedOutView.as_view(), name=routes.name(routes.LOGGED_OUT)),
path("error", views.server_error, name=routes.name(routes.SERVER_ERROR)),
path("<agency:agency>", views.AgencyIndexView.as_view(), name=routes.name(routes.AGENCY_INDEX)),
path("<agency:agency>/agency-card", views.AgencyCardView.as_view(), name=routes.name(routes.AGENCY_CARD)),
path("", IndexView.as_view(), name=routes.name(routes.INDEX)),
path("help", HelpView.as_view(), name=routes.name(routes.HELP)),
path("logged_out", LoggedOutView.as_view(), name=routes.name(routes.LOGGED_OUT)),
path("error", ServerErrorView.as_view(), name=routes.name(routes.SERVER_ERROR)),
path("<agency:agency>", AgencyIndexView.as_view(), name=routes.name(routes.AGENCY_INDEX)),
path("<agency:agency>/agency-card", AgencyCardView.as_view(), name=routes.name(routes.AGENCY_CARD)),
path(
"<agency:agency>/eligibility",
views.AgencyEligibilityIndexView.as_view(),
AgencyEligibilityIndexView.as_view(),
name=routes.name(routes.AGENCY_ELIGIBILITY_INDEX),
),
path("<agency:agency>/publickey", views.AgencyPublicKeyView.as_view(), name=routes.name(routes.AGENCY_PUBLIC_KEY)),
path("<agency:agency>/publickey", AgencyPublicKeyView.as_view(), name=routes.name(routes.AGENCY_PUBLIC_KEY)),
]
50 changes: 3 additions & 47 deletions benefits/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,16 @@
The core application: view definition for the root of the webapp.
"""

from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound, HttpResponseServerError
from django.template import loader
from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views.generic import RedirectView, TemplateView, View
from django.views.generic.edit import FormView

from benefits.core import models, session
from benefits.core.forms import ChooseAgencyForm
from benefits.core.middleware import pageview_decorator, user_error
from benefits.routes import routes

from . import models, session
from .middleware import index_or_agencyindex_origin_decorator, pageview_decorator, user_error

TEMPLATE_BAD_REQUEST = "400.html"
TEMPLATE_NOT_FOUND = "404.html"
TEMPLATE_SERVER_ERROR = "500.html"


class IndexView(FormView):
"""View handler for the main entry page."""
Expand Down Expand Up @@ -126,41 +120,3 @@ class LoggedOutView(TemplateView):
"""View handler for the final log out confirmation message."""

template_name = "core/logged-out.html"


@pageview_decorator
@index_or_agencyindex_origin_decorator
def bad_request(request, exception, template_name=TEMPLATE_BAD_REQUEST):
"""View handler for HTTP 400 Bad Request responses."""
t = loader.get_template(template_name)

return HttpResponseBadRequest(t.render(request=request))


@pageview_decorator
@index_or_agencyindex_origin_decorator
def csrf_failure(request, reason):
"""
View handler for CSRF_FAILURE_VIEW with custom data.
"""
t = loader.get_template(TEMPLATE_BAD_REQUEST)

return HttpResponseNotFound(t.render(request=request))


@pageview_decorator
@index_or_agencyindex_origin_decorator
def page_not_found(request, exception, template_name=TEMPLATE_NOT_FOUND):
"""View handler for HTTP 404 Not Found responses."""
t = loader.get_template(template_name)

return HttpResponseNotFound(t.render(request=request))


@pageview_decorator
@index_or_agencyindex_origin_decorator
def server_error(request, template_name=TEMPLATE_SERVER_ERROR):
"""View handler for HTTP 500 Server Error responses."""
t = loader.get_template(template_name)

return HttpResponseServerError(t.render(request=request))
2 changes: 1 addition & 1 deletion benefits/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def RUNTIME_ENVIRONMENT():

if not DEBUG:
CSRF_COOKIE_SECURE = True
CSRF_FAILURE_VIEW = "benefits.core.views.csrf_failure"
CSRF_FAILURE_VIEW = "benefits.views.csrf_failure_handler"
SESSION_COOKIE_SECURE = True

SECURE_BROWSER_XSS_FILTER = True
Expand Down
46 changes: 36 additions & 10 deletions benefits/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,24 @@

from django.conf import settings
from django.contrib import admin
from django.http import HttpResponse
from django.core.exceptions import BadRequest, PermissionDenied
from django.http import Http404, HttpResponse
from django.urls import include, path, re_path
from django.views.static import serve

from .core.admin.views import BenefitsPasswordResetConfirmView, BenefitsPasswordResetDoneView, BenefitsPasswordResetView
from benefits.core.admin.views import (
BenefitsPasswordResetConfirmView,
BenefitsPasswordResetDoneView,
BenefitsPasswordResetView,
)
from benefits.views import BadRequestView, ForbiddenView, NotFoundView, server_error_handler

logger = logging.getLogger(__name__)

handler400 = "benefits.core.views.bad_request"
handler403 = "benefits.core.views.bad_request"
handler404 = "benefits.core.views.page_not_found"
handler500 = "benefits.core.views.server_error"
handler400 = BadRequestView.as_view()
handler403 = ForbiddenView.as_view()
handler404 = NotFoundView.as_view()
handler500 = server_error_handler

urlpatterns = [
path("", include("benefits.core.urls")),
Expand All @@ -45,15 +51,35 @@
[re_path(r"^%s(?P<path>.*)$" % re.escape(prefix.lstrip("/")), serve, {"document_root": settings.MEDIA_ROOT})]
)

if settings.DEBUG:
# based on
# https://docs.sentry.io/platforms/python/guides/django/#verify
def trigger_400(request):
raise BadRequest("Test 400")

def trigger_error(request):
raise RuntimeError("Test error")
def trigger_403(request):
raise PermissionDenied("Test 403")

urlpatterns.append(path("testerror/", trigger_error))
def trigger_404(request):
raise Http404("Test 404")

def trigger_500(request):
raise Exception("Test 500")

def trigger_csrf(request):
if request.method == "POST":
return HttpResponse("Should not reach here")
return HttpResponse(
"<html><body><form method='post' action='/testcsrf/'>"
"<button type='submit'>Submit CSRF failure</button></form></body></html>"
)

urlpatterns.append(path("test400/", trigger_400))
urlpatterns.append(path("test403/", trigger_403))
urlpatterns.append(path("test404/", trigger_404))
urlpatterns.append(path("test500/", trigger_500))
urlpatterns.append(path("testcsrf/", trigger_csrf))

if settings.RUNTIME_ENVIRONMENT() in (settings.RUNTIME_ENVS.LOCAL, settings.RUNTIME_ENVS.DEV):
# simple route to read a pre-defined "secret"
# this "secret" does not contain sensitive information
# and is only configured in the dev environment for testing/debugging
Expand Down
81 changes: 81 additions & 0 deletions benefits/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from django.utils.decorators import method_decorator
from django.views.generic import TemplateView

from benefits.core.middleware import index_or_agencyindex_origin_decorator, pageview_decorator


class BaseErrorView(TemplateView):
"""Base view class for HTTP error handlers."""

status_code = 400

def __init__(self, **kwargs):
super().__init__(**kwargs)
self.template_name = self.template_name or f"{self.status_code}.html"

@method_decorator(pageview_decorator)
@method_decorator(index_or_agencyindex_origin_decorator)
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)

def render_to_response(self, context, **response_kwargs):
"""
Inject the custom status code into the TemplateResponse.

This is more idiomatic for CBVs than returning specialized subclasses
(like HttpResponseNotFound), as it allows TemplateView to handle
lazy rendering and context processors while ensuring the browser
receives the correct error status.
"""
response_kwargs.setdefault("status", self.status_code)
return super().render_to_response(context, **response_kwargs)

def post(self, request, *args, **kwargs):
"""
Handle POST requests by treating them as GET requests.
This prevents 405 errors when error handlers are triggered
by failed POST submissions (common with CSRF failures).
"""
return self.get(request, *args, **kwargs)


class BadRequestView(BaseErrorView):
"""View handler for HTTP 400 Bad Request responses."""

status_code = 400


class ForbiddenView(BaseErrorView):
"""View handler for HTTP 403 Forbidden responses. Returns a 403 response with the BadRequest template."""

status_code = 403
template_name = "400.html"


class CsrfFailureView(BaseErrorView):
"""View handler for CSRF_FAILURE_VIEW. Returns a 403 response with the BadRequest template."""

status_code = 403
template_name = "400.html"


def csrf_failure_handler(request, reason=""):
"""Wrapper function to satisfy CSRF_FAILURE_VIEW string resolution."""
return CsrfFailureView.as_view()(request, reason=reason)


class NotFoundView(BaseErrorView):
"""View handler for HTTP 404 Not Found responses."""

status_code = 404


class ServerErrorView(BaseErrorView):
"""View handler for HTTP 500 Server Error responses."""

status_code = 500


def server_error_handler(request):
"""Wrapper function to satisfy handler500 system check (urls.E007)."""
return ServerErrorView.as_view()(request)
77 changes: 0 additions & 77 deletions tests/pytest/core/test_views.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
import pytest
from django.urls import reverse

import benefits.core.session
from benefits.core import views
from benefits.core.middleware import TEMPLATE_USER_ERROR
from benefits.core.models import EnrollmentFlow
from benefits.core.views import (
bad_request,
csrf_failure,
page_not_found,
server_error,
)
from benefits.routes import routes


Expand Down Expand Up @@ -194,73 +187,3 @@ def view(self, app_request):

def test_view(self, view):
assert view.template_name == "core/logged-out.html"


@pytest.mark.django_db
def test_bad_request_active_agency(app_request, mocked_session_agency, mocked_session_update):
response = bad_request(app_request, Exception())

assert response.status_code == 400
assert "origin" in mocked_session_update.call_args.kwargs
assert mocked_session_update.call_args.kwargs["origin"] == mocked_session_agency.return_value.index_url


@pytest.mark.django_db
def test_bad_request_no_active_agency(app_request, mocked_session_update):
response = bad_request(app_request, Exception())

assert response.status_code == 400
assert "origin" in mocked_session_update.call_args.kwargs
assert mocked_session_update.call_args.kwargs["origin"] == reverse(routes.INDEX)


@pytest.mark.django_db
def test_csrf_failure_active_agency(app_request, mocked_session_agency, mocked_session_update):
response = csrf_failure(app_request, "reason")

assert response.status_code == 404
assert "origin" in mocked_session_update.call_args.kwargs
assert mocked_session_update.call_args.kwargs["origin"] == mocked_session_agency.return_value.index_url


@pytest.mark.django_db
def test_csrf_failure_no_active_agency(app_request, mocked_session_update):
response = csrf_failure(app_request, "reason")

assert response.status_code == 404
assert "origin" in mocked_session_update.call_args.kwargs
assert mocked_session_update.call_args.kwargs["origin"] == reverse(routes.INDEX)


@pytest.mark.django_db
def test_not_found_active_agency(mocker, client, mocked_session_agency, mocked_session_update):
response = client.get("/not-found")

assert response.status_code == 404
assert "origin" in mocked_session_update.call_args.kwargs
assert mocked_session_update.call_args.kwargs["origin"] == mocked_session_agency.return_value.index_url


@pytest.mark.django_db
def test_not_found_no_active_agency(mocker, client, mocked_session_update):
mocker.patch("benefits.core.session.active_agency", return_value=False)

response = client.get("/not-found")

assert response.status_code == 404
assert "origin" in mocked_session_update.call_args.kwargs
assert mocked_session_update.call_args.kwargs["origin"] == reverse(routes.INDEX)


@pytest.mark.django_db
def test_page_not_found(app_request):
response = page_not_found(app_request, Exception())

assert response.status_code == 404


@pytest.mark.django_db
def test_server_error(app_request):
response = server_error(app_request)

assert response.status_code == 500
Loading
Loading