Repository: cal-itp/benefits

Issue:
Move all 404/500/error pages to the One Column Layout
## Acceptance Criteria

<!-- Remember to consider edge cases -->

- [x] 404, 500 pages move to 1 column layout
- [x] Use the “Enrollment redirect” error mock for 404s and 500s resulting from the user not entering the app at the correct phase, e.g. no agency selected, no verifier selected, not verified as eligible yet

## Additional context

See Figma


Related to #913 https://github.com/cal-itp/benefits/issues/757

Better handling of visiting restricted pages
There's probably a better way to word that title.

## To Reproduce

Steps to reproduce the behavior:

1. Open an Incognito/Private Window
2. Go to https://test-benefits.calitp.org/enrollment/
3. See `Service is down` error message

## Expected behavior

I would expect the user to get redirected to the start of the flow, or at least shown a message like "we need you to do a few things before you can access this page."

## Screenshots

<img width="1552" alt="Screen Shot 2022-06-29 at 1 19 54 PM" src="https://user-images.githubusercontent.com/86842/176497673-20ccc861-62d7-4dcc-9755-8c289d5a28d2.png">

## Desktop

- OS: macOS 12.4
- Browser: Firefox v101.0.1

## Additional context

n/a


Discussion:

@orenyk just ran into this (presumably copying the `/eligibility/start` URL after being redirected) and thought our site was down. 

Resolving patch:
diff --git a/benefits/core/middleware.py b/benefits/core/middleware.py
--- a/benefits/core/middleware.py
+++ b/benefits/core/middleware.py
@@ -8,6 +8,7 @@
 from django.http import HttpResponse, HttpResponseBadRequest
 from django.shortcuts import redirect
 from django.template import loader
+from django.template.response import TemplateResponse
 from django.utils.decorators import decorator_from_middleware
 from django.utils.deprecation import MiddlewareMixin
 from django.views import i18n
@@ -17,6 +18,14 @@
 
 logger = logging.getLogger(__name__)
 HEALTHCHECK_PATH = "/healthcheck"
+TEMPLATE_USER_ERROR = "200_user_error.html"
+
+
+def user_error(request):
+    home = viewmodels.Button.home(request)
+    page = viewmodels.ErrorPage.user_error(button=home)
+
+    return TemplateResponse(request, TEMPLATE_USER_ERROR, page.context_dict())
 
 
 class AgencySessionRequired(MiddlewareMixin):
@@ -27,7 +36,8 @@ def process_request(self, request):
             logger.debug("Session configured with agency")
             return None
         else:
-            raise AttributeError("Session not configured with agency")
+            logger.debug("Session not configured with agency")
+            return user_error(request)
 
 
 class RateLimit(MiddlewareMixin):
@@ -52,7 +62,7 @@ def process_request(self, request):
             if reset_time > now:
                 logger.warning("Rate limit exceeded")
                 home = viewmodels.Button.home(request)
-                page = viewmodels.ErrorPage.error(
+                page = viewmodels.ErrorPage.server_error(
                     title="Rate limit error",
                     content_title="Rate limit error",
                     paragraphs=["You have reached the rate limit. Please try again."],
@@ -75,7 +85,8 @@ def process_request(self, request):
             logger.debug("Session has confirmed eligibility")
             return None
         else:
-            raise AttributeError("Session has no confirmed eligibility")
+            logger.debug("Session has no confirmed eligibility")
+            return user_error(request)
 
 
 class DebugSession(MiddlewareMixin):
@@ -106,7 +117,8 @@ def process_request(self, request):
             logger.debug("Session configured with eligibility verifier")
             return None
         else:
-            raise AttributeError("Session not configured with eligibility verifier")
+            logger.debug("Session not configured with eligibility verifier")
+            return user_error(request)
 
 
 class ViewedPageEvent(MiddlewareMixin):
diff --git a/benefits/core/viewmodels.py b/benefits/core/viewmodels.py
--- a/benefits/core/viewmodels.py
+++ b/benefits/core/viewmodels.py
@@ -168,16 +168,27 @@ def __init__(self, **kwargs):
             content_title=kwargs.get("content_title", _("core.pages.error.title")),
             paragraphs=kwargs.get("paragraphs", [_("core.pages.server_error.content_title")]),
             button=kwargs.get("button"),
+            noimage=True,
         )
 
     @staticmethod
-    def error(
+    def user_error(
+        title=_("core.pages.user_error.title"),
+        content_title=_("core.pages.user_error.content_title"),
+        paragraphs=[_("core.pages.user_error.p[0]")],
+        **kwargs,
+    ):
+        """Create a new core.viewmodels.ErrorPage instance with defaults for a user error."""
+        return ErrorPage(title=title, content_title=content_title, paragraphs=paragraphs, **kwargs)
+
+    @staticmethod
+    def server_error(
         title=_("core.pages.server_error.title"),
         content_title=_("core.pages.server_error.title"),
         paragraphs=[_("core.pages.server_error.p[0]"), _("core.pages.server_error.p[1]")],
         **kwargs,
     ):
-        """Create a new core.viewmodels.ErrorPage instance with defaults for a generic error."""
+        """Create a new core.viewmodels.ErrorPage instance with defaults for a generic server error."""
         return ErrorPage(title=title, content_title=content_title, paragraphs=paragraphs, **kwargs)
 
     @staticmethod
diff --git a/benefits/core/views.py b/benefits/core/views.py
--- a/benefits/core/views.py
+++ b/benefits/core/views.py
@@ -107,7 +107,7 @@ def bad_request(request, exception, template_name="400.html"):
         session.update(request, origin=reverse(ROUTE_INDEX))
 
     home = viewmodels.Button.home(request)
-    page = viewmodels.ErrorPage.error(button=home)
+    page = viewmodels.ErrorPage.server_error(button=home)
     t = loader.get_template(template_name)
 
     return HttpResponseBadRequest(t.render(page.context_dict()))
@@ -139,7 +139,8 @@ def page_not_found(request, exception, template_name="404.html"):
         session.update(request, origin=reverse(ROUTE_INDEX))
 
     home = viewmodels.Button.home(request)
-    page = viewmodels.ErrorPage.not_found(button=home, path=request.path)
+    # show a more user-friendly message instead of not_found
+    page = viewmodels.ErrorPage.user_error(button=home, path=request.path)
     t = loader.get_template(template_name)
 
     return HttpResponseNotFound(t.render(page.context_dict()))
@@ -154,7 +155,7 @@ def server_error(request, template_name="500.html"):
         session.update(request, origin=reverse(ROUTE_INDEX))
 
     home = viewmodels.Button.home(request)
-    page = viewmodels.ErrorPage.error(button=home)
+    page = viewmodels.ErrorPage.server_error(button=home)
     t = loader.get_template(template_name)
 
     return HttpResponseServerError(t.render(page.context_dict()))


Test patch:
diff --git a/tests/pytest/core/test_viewmodels.py b/tests/pytest/core/test_viewmodels.py
--- a/tests/pytest/core/test_viewmodels.py
+++ b/tests/pytest/core/test_viewmodels.py
@@ -1,16 +1,43 @@
 from django.utils import translation
 from django.utils.translation import gettext as _
+import pytest
 
 from benefits.core.viewmodels import ErrorPage
 
 
-def test_ErrorPage_error_translations():
+@pytest.mark.parametrize(
+    "error_page_function,title,content_title,paragraphs",
+    [
+        pytest.param(
+            ErrorPage.server_error,
+            "core.pages.server_error.title",
+            "core.pages.server_error.title",
+            ["core.pages.server_error.p[0]", "core.pages.server_error.p[1]"],
+            id="server_error",
+        ),
+        pytest.param(
+            ErrorPage.not_found,
+            "core.pages.not_found.title",
+            "core.pages.not_found.content_title",
+            ["core.pages.not_found.p[0]"],
+            id="not_found",
+        ),
+        pytest.param(
+            ErrorPage.user_error,
+            "core.pages.user_error.title",
+            "core.pages.user_error.content_title",
+            ["core.pages.user_error.p[0]"],
+            id="user_error",
+        ),
+    ],
+)
+def test_ErrorPage_translations(error_page_function, title, content_title, paragraphs):
     translation.activate("en")
 
-    error_page = ErrorPage.error()
-    english_title = f"{_('core.pages.index.prefix')}: {_('core.pages.server_error.title')}"
-    english_content_title = _("core.pages.server_error.title")
-    english_paragraphs = [_("core.pages.server_error.p[0]"), _("core.pages.server_error.p[1]")]
+    error_page = error_page_function()
+    english_title = f"{_('core.pages.index.prefix')}: {_(title)}"
+    english_content_title = _(content_title)
+    english_paragraphs = [_(paragraph) for paragraph in paragraphs]
 
     assert error_page.title == english_title
     assert error_page.content_title == english_content_title
@@ -18,38 +45,10 @@ def test_ErrorPage_error_translations():
 
     translation.activate("es")
 
-    error_page = ErrorPage.error()
-    spanish_title = f"{_('core.pages.index.prefix')}: {_('core.pages.server_error.title')}"
-    spanish_content_title = _("core.pages.server_error.title")
-    spanish_paragraphs = [_("core.pages.server_error.p[0]"), _("core.pages.server_error.p[1]")]
-
-    assert error_page.title != english_title
-    assert error_page.content_title != english_content_title
-    assert error_page.paragraphs != english_paragraphs
-
-    assert error_page.title == spanish_title
-    assert error_page.content_title == spanish_content_title
-    assert error_page.paragraphs == spanish_paragraphs
-
-
-def test_ErrorPage_not_found_translations():
-    translation.activate("en")
-
-    error_page = ErrorPage.not_found()
-    english_title = f"{_('core.pages.index.prefix')}: {_('core.pages.not_found.title')}"
-    english_content_title = _("core.pages.not_found.content_title")
-    english_paragraphs = [_("core.pages.not_found.p[0]")]
-
-    assert error_page.title == english_title
-    assert error_page.content_title == english_content_title
-    assert error_page.paragraphs == english_paragraphs
-
-    translation.activate("es")
-
-    error_page = ErrorPage.not_found()
-    spanish_title = f"{_('core.pages.index.prefix')}: {_('core.pages.not_found.title')}"
-    spanish_content_title = _("core.pages.not_found.content_title")
-    spanish_paragraphs = [_("core.pages.not_found.p[0]")]
+    error_page = error_page_function()
+    spanish_title = f"{_('core.pages.index.prefix')}: {_(title)}"
+    spanish_content_title = _(content_title)
+    spanish_paragraphs = [_(paragraph) for paragraph in paragraphs]
 
     assert error_page.title != english_title
     assert error_page.content_title != english_content_title
diff --git a/tests/pytest/eligibility/test_views.py b/tests/pytest/eligibility/test_views.py
--- a/tests/pytest/eligibility/test_views.py
+++ b/tests/pytest/eligibility/test_views.py
@@ -3,6 +3,7 @@
 import pytest
 
 
+from benefits.core.middleware import TEMPLATE_USER_ERROR
 from benefits.eligibility.forms import EligibilityVerifierSelectionForm
 from benefits.eligibility.views import (
     ROUTE_INDEX,
@@ -97,8 +98,11 @@ def test_index_get_agency_single_verifier(
 @pytest.mark.django_db
 def test_index_get_without_agency(client):
     path = reverse(ROUTE_INDEX)
-    with pytest.raises(AttributeError, match=r"agency"):
-        client.get(path)
+
+    response = client.get(path)
+
+    assert response.status_code == 200
+    assert response.template_name == TEMPLATE_USER_ERROR
 
 
 @pytest.mark.django_db
@@ -170,8 +174,10 @@ def test_start_verifier_auth_not_required(client):
 @pytest.mark.usefixtures("mocked_session_agency")
 def test_start_without_verifier(client):
     path = reverse(ROUTE_START)
-    with pytest.raises(AttributeError, match=r"verifier"):
-        client.get(path)
+
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.template_name == TEMPLATE_USER_ERROR
 
 
 @pytest.mark.django_db
diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py
--- a/tests/pytest/enrollment/test_views.py
+++ b/tests/pytest/enrollment/test_views.py
@@ -4,6 +4,7 @@
 
 import pytest
 
+from benefits.core.middleware import TEMPLATE_USER_ERROR
 from benefits.enrollment.views import (
     ROUTE_INDEX,
     ROUTE_TOKEN,
@@ -35,8 +36,11 @@ def mocked_analytics_module(mocked_analytics_module):
 @pytest.mark.django_db
 def test_token_ineligible(client):
     path = reverse(ROUTE_TOKEN)
-    with pytest.raises(AttributeError, match=r"eligibility"):
-        client.get(path)
+
+    response = client.get(path)
+
+    assert response.status_code == 200
+    assert response.template_name == TEMPLATE_USER_ERROR
 
 
 @pytest.mark.django_db
@@ -132,15 +136,21 @@ def test_index_eligible_post_valid_form_success(
 @pytest.mark.django_db
 def test_index_ineligible(client):
     path = reverse(ROUTE_INDEX)
-    with pytest.raises(AttributeError, match=r"eligibility"):
-        client.get(path)
+
+    response = client.get(path)
+
+    assert response.status_code == 200
+    assert response.template_name == TEMPLATE_USER_ERROR
 
 
 @pytest.mark.django_db
 def test_retry_ineligible(client):
     path = reverse(ROUTE_RETRY)
-    with pytest.raises(AttributeError, match=r"eligibility"):
-        client.post(path)
+
+    response = client.post(path)
+
+    assert response.status_code == 200
+    assert response.template_name == TEMPLATE_USER_ERROR
 
 
 @pytest.mark.django_db
@@ -177,8 +187,11 @@ def test_retry_valid_form(mocker, client, mocked_analytics_module):
 @pytest.mark.django_db
 def test_success_no_verifier(client):
     path = reverse(ROUTE_SUCCESS)
-    with pytest.raises(AttributeError, match=r"verifier"):
-        client.get(path)
+
+    response = client.get(path)
+
+    assert response.status_code == 200
+    assert response.template_name == TEMPLATE_USER_ERROR
 
 
 @pytest.mark.django_db