Repository: fedora-infra/bodhi

Issue:
Bodhi's update create form does not gracefully handle errors from the backend
When users were hitting https://github.com/fedora-infra/bodhi/issues/3024, the web form's submit button would spin until it hit the error and then it would stop spinning, but no error was reported to the user. This made it non-obvious that there was a problem, and also made it more difficult to determine what the real problem was.

The web form (and all of Bodhi's forms) should be able to display errors from the backend to the user on non-2xx codes.


Discussion:
It looks like duplication of #2312

Resolving patch:
diff --git a/bodhi/server/views/generic.py b/bodhi/server/views/generic.py
--- a/bodhi/server/views/generic.py
+++ b/bodhi/server/views/generic.py
@@ -1,4 +1,4 @@
-# Copyright © 2014-2017 Red Hat, Inc. and others
+# Copyright © 2014-2019 Red Hat, Inc. and others
 #
 # This file is part of Bodhi.
 #
@@ -411,14 +411,14 @@ def notfound_view(context, request):
         bodhi.server.services.errors.html_handler: A pyramid.httpexceptions.HTTPError to be rendered
             to the user for the 404.
     """
-    return exception_view(context, request)
+    return exception_html_view(context, request)
 
 
-@view_config(context=HTTPForbidden)
-@view_config(context=Exception)
-def exception_view(exc, request):
+@view_config(context=HTTPForbidden, accept='text/html')
+@view_config(context=Exception, accept='text/html')
+def exception_html_view(exc, request):
     """
-    Return an error response upon generic errors (404s, 403s, 500s, etc..).
+    Return a html error response upon generic errors (404s, 403s, 500s, etc..).
 
     This is here to catch everything that isn't caught by our cornice error
     handlers.  When we do catch something, we transform it into a cornice
@@ -448,3 +448,39 @@ def exception_view(exc, request):
         request.errors = errors
 
     return bodhi.server.services.errors.html_handler(request)
+
+
+@view_config(context=HTTPForbidden, accept='application/json')
+@view_config(context=Exception, accept='application/json')
+def exception_json_view(exc, request):
+    """
+    Return a json error response upon generic errors (404s, 403s, 500s, etc..).
+
+    This is here to catch everything that isn't caught by our cornice error
+    handlers.  When we do catch something, we transform it into a cornice
+    Errors object and pass it to our nice cornice error handler.  That way, all
+    the exception presentation and rendering we can keep in one place.
+
+    Args:
+        exc (Exception): The unhandled exception.
+        request (pyramid.util.Request): The current request.
+    Returns:
+        bodhi.server.services.errors.html_handler: A pyramid.httpexceptions.HTTPError to be rendered
+            to the user for the given exception.
+    """
+    errors = getattr(request, 'errors', [])
+    status = getattr(exc, 'status_code', 500)
+
+    if status not in (404, 403):
+        log.exception("Error caught.  Handling JSON response.")
+    else:
+        log.warning(str(exc))
+
+    if not len(errors):
+        description = getattr(exc, 'explanation', None) or str(exc)
+
+        errors = cornice.errors.Errors(status=status)
+        errors.add('body', description=description, name=exc.__class__.__name__)
+        request.errors = errors
+
+    return bodhi.server.services.errors.json_handler(request)


Test patch:
diff --git a/bodhi/tests/server/views/test_generic.py b/bodhi/tests/server/views/test_generic.py
--- a/bodhi/tests/server/views/test_generic.py
+++ b/bodhi/tests/server/views/test_generic.py
@@ -35,14 +35,34 @@ class TestExceptionView(base.BaseTestCase):
     @mock.patch('bodhi.server.views.generic.log.exception')
     @mock.patch('bodhi.server.views.metrics.compute_ticks_and_data',
                 mock.MagicMock(side_effect=RuntimeError("BOOM")))
-    def test_status_500(self, exception):
+    def test_status_500_html(self, exception):
         """Assert that a status 500 code causes the exception to get logged."""
-        res = self.app.get('/metrics', status=500)
+        headers = {'Accept': 'text/html'}
+        res = self.app.get('/metrics', status=500, headers=headers)
 
+        self.assertEqual('text/html', res.content_type)
         self.assertIn('Server Error', res)
         self.assertIn('BOOM', res)
         exception.assert_called_once_with("Error caught.  Handling HTML response.")
 
+    @mock.patch('bodhi.server.views.generic.log.exception')
+    @mock.patch('bodhi.server.views.metrics.compute_ticks_and_data',
+                mock.MagicMock(side_effect=RuntimeError("BOOM")))
+    def test_status_500_json(self, exception):
+        """Assert that a status 500 code causes the exception to get logged."""
+        headers = {'Content-Type': 'application/json'}
+        res = self.app.get('/metrics', status=500, headers=headers)
+
+        self.assertEqual('application/json', res.content_type)
+        self.assertEqual(
+            res.json_body,
+            {
+                "status": "error",
+                "errors": [{"location": "body", "name": "RuntimeError", "description": "BOOM"}]
+            },
+        )
+        exception.assert_called_once_with("Error caught.  Handling JSON response.")
+
 
 class TestGenericViews(base.BaseTestCase):
 
@@ -342,15 +362,18 @@ def test_version(self):
 
     def test_popup_toggle(self):
         """Check that the toggling of pop-up notifications works"""
+
+        headers = {'Accept': 'text/html'}
+
         # first we check that popups are enabled by default
-        res = self.app.get('/')
+        res = self.app.get('/', headers=headers)
         self.assertIn('Disable popups', res)
 
         # toggle popups off
         self.app.post('/popup_toggle')
 
         # now check popups are off
-        res = self.app.get('/')
+        res = self.app.get('/', headers=headers)
         self.assertIn('Enable popups', res)
 
         # test that the unlogged in user cannot toggle popups
@@ -360,15 +383,17 @@ def test_popup_toggle(self):
             'authtkt.secure': True,
         })
         app = webtest.TestApp(main({}, session=self.db, **anonymous_settings))
-        res = app.post('/popup_toggle', status=403)
+        res = app.post('/popup_toggle', status=403, headers=headers)
         self.assertIn('<h1>403 <small>Forbidden</small></h1>', res)
         self.assertIn('<p class="lead">Access was denied to this resource.</p>', res)
 
     def test_new_override_form(self):
         """Test the New Override form page"""
 
+        headers = {'Accept': 'text/html'}
+
         # Test that the New Override form shows when logged in
-        res = self.app.get('/overrides/new')
+        res = self.app.get('/overrides/new', headers=headers)
         self.assertIn('<span>New Buildroot Override Form Requires JavaScript</span>', res)
 
         # Test that the unlogged in user cannot see the New Override form
@@ -378,15 +403,17 @@ def test_new_override_form(self):
             'authtkt.secure': True,
         })
         app = webtest.TestApp(main({}, session=self.db, **anonymous_settings))
-        res = app.get('/overrides/new', status=403)
+        res = app.get('/overrides/new', status=403, headers=headers)
         self.assertIn('<h1>403 <small>Forbidden</small></h1>', res)
         self.assertIn('<p class="lead">Access was denied to this resource.</p>', res)
 
     def test_new_update_form(self):
         """Test the new update Form page"""
 
+        headers = {'Accept': 'text/html'}
+
         # Test that a logged in user sees the New Update form
-        res = self.app.get('/updates/new')
+        res = self.app.get('/updates/new', headers=headers)
         self.assertIn('Creating a new update requires JavaScript', res)
         # Make sure that unspecified comes first, as it should be the default.
         regex = r''
@@ -401,7 +428,7 @@ def test_new_update_form(self):
             'authtkt.secure': True,
         })
         app = webtest.TestApp(main({}, session=self.db, **anonymous_settings))
-        res = app.get('/updates/new', status=403)
+        res = app.get('/updates/new', status=403, headers=headers)
         self.assertIn('<h1>403 <small>Forbidden</small></h1>', res)
         self.assertIn('<p class="lead">Access was denied to this resource.</p>', res)