Repository: fedora-infra/bodhi

Issue:
Staging is currently returning HTML to bodhi CLI requests
I am not sure why this is happening, but it seems that staging Bodhi is currently returning HTML to CLI requests. This also happens to requests with ```http``` or ```curl```.

I recall a problem with the unit tests where they would sometimes receive HTML when they didn't explicitly use a request header to ask for a JSON response once we started testing under Python 3. We ended up adjusting the tests to pass that header since this did not seem to happen when serving Bodhi with ```pserve-3```.

It turns out that there really is some problem that seems related to Python 3 since staging Bodhi started doing this same thing.


Discussion:
I've discovered that the order of the decorators over the services seems to make a difference on which content type gets returned in the unit tests when no request header is present. It seems that moving the html handler to be the outermost decorator (i.e., the first one in code order) will make the JSON response be the default, which is what we want.
Unfortunately, I am unable to find any documentation about this behavior at https://cornice.readthedocs.io.
It seems that Pyramid documents the behavior I am seeing. See the "accept" section [here](https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/viewconfig.html#non-predicate-arguments):

> A media type that will be matched against the Accept HTTP request header. If this value is specified, it must be a specific media type such as text/html or text/html;level=1. If the media type is acceptable by the Accept header of the request, or *if the Accept header isn't set at all in the request*, this predicate will match. If this does not match the Accept header of the request, view matching continues.

Emphasis mine. It sounds like plain requests could match any of the view configs with the way we use the accept header here.
My earlier comment was premature - it seems that the order of the decorators does not matter - the view that Pyramid seems to choose when there is no request header seems to be different every request. Repeated runs of the same tests sometimes return html and othertimes return json. Fun!
A reasonable solution is probably to inject an ```Accept``` header into requests that do not have them before view matching happens. Hopefully there is a Pyramid hook I can use to do this.

Resolving patch:
diff --git a/bodhi/server/webapp.py b/bodhi/server/webapp.py
--- a/bodhi/server/webapp.py
+++ b/bodhi/server/webapp.py
@@ -50,13 +50,25 @@ def _complete_database_session(request):
 @subscriber(NewRequest)
 def _prepare_request(event):
     """
-    Add callbacks onto every new request.
+    Prepare each incoming request to Bodhi.
 
-    This function adds a callback to clean up the database session when the request is finished.
+    This function does two things:
+        * If requests do not have an Accept header, or if their Accept header is "*/*", it sets the
+          header to application/json. Pyramid has undefined behavior when an ambiguous or missing
+          Accept header is received, and multiple views are defined that handle specific Accept
+          headers. For example, we have a view that returns html or JSON for /composes/, depending
+          on the Accept header, but if a request has no Accept header or has */*, Pyramid will
+          consider both views to be a match for the request and so it is undefined which view will
+          handle the request. Let's force ambibuous requests to receive a JSON response so we have a
+          defined behavior. See https://github.com/fedora-infra/bodhi/issues/2731.
+        * It adds a callback to clean up the database session when the request is finished.
 
     Args:
         event (pyramid.events.NewRequest): The new request event.
     """
+    if 'Accept' not in event.request.headers or event.request.headers['Accept'] == '*/*':
+        event.request.headers['Accept'] = 'application/json'
+
     event.request.add_finished_callback(_complete_database_session)
 
 


Test patch:
diff --git a/bodhi/tests/server/base.py b/bodhi/tests/server/base.py
--- a/bodhi/tests/server/base.py
+++ b/bodhi/tests/server/base.py
@@ -88,25 +88,6 @@ def restart_savepoint(session, transaction):
     return engine
 
 
-class BodhiTestApp(TestApp):
-    """
-    Subclass of webtest.TestApp.
-
-    This class autimaticaly adds {'accept': 'application/json'} header
-    to every GET request where 'accept' header is not specified.
-    """
-
-    def get(self, *args, **kwargs):
-        """GET call with automatic Accept headers."""
-        if 'headers' not in kwargs:
-            kwargs['headers'] = {'Accept': 'application/json'}
-        else:
-            headers_set = set([key.lower() for key in kwargs['headers']])
-            if 'accept' not in headers_set:
-                kwargs['headers']['accept'] = 'application/json'
-        return super(BodhiTestApp, self).get(*args, **kwargs)
-
-
 class BaseTestCase(unittest.TestCase):
     """
     The base test class for Bodhi.
@@ -200,7 +181,7 @@ def setUp(self):
             # trigger the restart_savepoint() callback defined above which will remove the data
             # added by populate().
             with mock.patch('bodhi.server.Session.remove'):
-                _app = BodhiTestApp(main({}, testing=u'guest', **self.app_settings))
+                _app = TestApp(main({}, testing=u'guest', **self.app_settings))
         self.app = _app
 
     def get_csrf_token(self, app=None):
@@ -208,7 +189,7 @@ def get_csrf_token(self, app=None):
         Return a CSRF token that can be used by tests as they test the REST API.
 
         Args:
-            app (BodhiTestApp): The app to use to get the token. Defaults to None, which will use
+            app (TestApp): The app to use to get the token. Defaults to None, which will use
                 self.app.
         Returns:
             basestring: A CSRF token.
diff --git a/bodhi/tests/server/functional/test_admin.py b/bodhi/tests/server/functional/test_admin.py
--- a/bodhi/tests/server/functional/test_admin.py
+++ b/bodhi/tests/server/functional/test_admin.py
@@ -1,4 +1,4 @@
-# Copyright 2017 Red Hat, Inc.
+# Copyright 2018 Red Hat, Inc.
 #
 # This file is part of Bodhi.
 #
@@ -18,6 +18,8 @@
 
 import copy
 
+import webtest
+
 from bodhi.server import main
 from bodhi.tests.server import base
 
@@ -40,7 +42,7 @@ def test_admin_unauthed(self):
             'authtkt.secret': 'whatever',
             'authtkt.secure': True,
         })
-        app = base.BodhiTestApp(main({}, session=self.db, **anonymous_settings))
+        app = webtest.TestApp(main({}, session=self.db, **anonymous_settings))
         res = app.get('/admin/', status=403)
         self.assertIn('<h1>403 <small>Forbidden</small></h1>', res)
         self.assertIn('<p class="lead">Access was denied to this resource.</p>', res)
diff --git a/bodhi/tests/server/functional/test_stacks.py b/bodhi/tests/server/functional/test_stacks.py
--- a/bodhi/tests/server/functional/test_stacks.py
+++ b/bodhi/tests/server/functional/test_stacks.py
@@ -1,3 +1,8 @@
+# -*- coding: utf-8 -*-
+# Copyright © 2014-2018 Red Hat, Inc.
+#
+# This file is part of Bodhi.
+#
 # This program is free software; you can redistribute it and/or
 # modify it under the terms of the GNU General Public License
 # as published by the Free Software Foundation; either version 2
@@ -15,6 +20,7 @@
 import copy
 
 import mock
+import webtest
 
 from bodhi.server import main
 from bodhi.server.models import Group, RpmPackage, Stack, User
@@ -268,7 +274,7 @@ def test_new_stack_form_unauthed(self):
             'authtkt.secret': 'whatever',
             'authtkt.secure': True,
         })
-        app = base.BodhiTestApp(main({}, session=self.db, **anonymous_settings))
+        app = webtest.TestApp(main({}, session=self.db, **anonymous_settings))
         res = app.get('/stacks/new', status=403)
         self.assertIn('<h1>403 <small>Forbidden</small></h1>', res)
         self.assertIn('<p class="lead">Access was denied to this resource.</p>', res)
diff --git a/bodhi/tests/server/services/test_comments.py b/bodhi/tests/server/services/test_comments.py
--- a/bodhi/tests/server/services/test_comments.py
+++ b/bodhi/tests/server/services/test_comments.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-# Copyright © 2014-2017 Red Hat, Inc. and others.
+# Copyright © 2014-2018 Red Hat, Inc. and others.
 #
 # This file is part of Bodhi.
 #
@@ -21,6 +21,7 @@
 from datetime import datetime, timedelta
 
 import mock
+import webtest
 
 from bodhi.server import main
 from bodhi.server.models import (Comment, Release, Update, UpdateRequest, UpdateStatus, UpdateType,
@@ -245,7 +246,7 @@ def test_anonymous_commenting_with_no_author(self):
             'authtkt.secure': True,
         })
         with mock.patch('bodhi.server.Session.remove'):
-            app = base.BodhiTestApp(main({}, session=self.db, **anonymous_settings))
+            app = webtest.TestApp(main({}, session=self.db, **anonymous_settings))
 
         comment = {
             u'update': 'bodhi-2.0-1.fc17',
diff --git a/bodhi/tests/server/services/test_composes.py b/bodhi/tests/server/services/test_composes.py
--- a/bodhi/tests/server/services/test_composes.py
+++ b/bodhi/tests/server/services/test_composes.py
@@ -51,6 +51,17 @@ def test___acl__(self):
 
 class TestComposeCollectionGet(base.BaseTestCase):
     """This class contains tests for the Compose.collection_get() method."""
+    def test_default_accept(self):
+        """Test that an Accept header of */* gets the default JSON response."""
+        update = models.Update.query.first()
+        compose = models.Compose(release=update.release, request=update.request)
+        self.db.add(compose)
+        self.db.flush()
+
+        response = self.app.get('/composes/', status=200, headers={'Accept': '*/*'})
+
+        self.assertEqual(response.json, {'composes': [compose.__json__()]})
+
     def test_no_composes_html(self):
         """Assert correct behavior for html interface when there are no composes."""
         response = self.app.get('/composes/', status=200, headers={'Accept': 'text/html'})
diff --git a/bodhi/tests/server/services/test_overrides.py b/bodhi/tests/server/services/test_overrides.py
--- a/bodhi/tests/server/services/test_overrides.py
+++ b/bodhi/tests/server/services/test_overrides.py
@@ -21,6 +21,7 @@
 import copy
 
 import mock
+import webtest
 
 from bodhi.server.models import BuildrootOverride, RpmBuild, RpmPackage, Release, User
 from bodhi.server import main
@@ -681,7 +682,7 @@ def test_override_view_not_loggedin(self):
             'authtkt.secure': True,
         })
         with mock.patch('bodhi.server.Session.remove'):
-            app = base.BodhiTestApp(main({}, session=self.db, **anonymous_settings))
+            app = webtest.TestApp(main({}, session=self.db, **anonymous_settings))
 
         resp = app.get('/overrides/bodhi-2.0-1.fc17',
                        status=200, headers={'Accept': 'text/html'})
@@ -707,7 +708,7 @@ def test_override_new_not_loggedin(self):
             'authtkt.secret': 'whatever',
             'authtkt.secure': True,
         })
-        app = base.BodhiTestApp(main({}, session=self.db, **anonymous_settings))
+        app = webtest.TestApp(main({}, session=self.db, **anonymous_settings))
         resp = app.get('/overrides/new',
                        status=403, headers={'Accept': 'text/html'})
         self.assertIn('<h1>403 <small>Forbidden</small></h1>', resp)
diff --git a/bodhi/tests/server/services/test_releases.py b/bodhi/tests/server/services/test_releases.py
--- a/bodhi/tests/server/services/test_releases.py
+++ b/bodhi/tests/server/services/test_releases.py
@@ -19,6 +19,8 @@
 import mock
 import os
 
+import webtest
+
 from bodhi import server
 from bodhi.server.config import config
 from bodhi.server.models import Release, ReleaseState, Update
@@ -54,7 +56,7 @@ def test_anonymous_cant_edit_release(self):
         name = u"F22"
         # Create a new app so we are the anonymous user.
         with mock.patch('bodhi.server.Session.remove'):
-            app = base.BodhiTestApp(server.main({}, session=self.db, **self.app_settings))
+            app = webtest.TestApp(server.main({}, session=self.db, **self.app_settings))
 
         res = app.get('/releases/%s' % name, status=200)
         r = res.json_body
diff --git a/bodhi/tests/server/services/test_updates.py b/bodhi/tests/server/services/test_updates.py
--- a/bodhi/tests/server/services/test_updates.py
+++ b/bodhi/tests/server/services/test_updates.py
@@ -28,6 +28,7 @@
 import requests
 import six
 from six.moves.urllib import parse as urlparse
+from webtest import TestApp
 
 from bodhi.server import main
 from bodhi.server.config import config
@@ -36,7 +37,7 @@
     ReleaseState, RpmBuild, Update, UpdateRequest, UpdateStatus, UpdateType,
     UpdateSeverity, UpdateSuggestion, User, TestGatingStatus)
 from bodhi.server.util import call_api
-from bodhi.tests.server.base import BaseTestCase, BodhiTestApp
+from bodhi.tests.server.base import BaseTestCase
 
 
 YEAR = time.localtime().tm_year
@@ -163,7 +164,7 @@ def test_no_privs(self, publish, *args):
         self.db.add(user)
         self.db.commit()
         with mock.patch('bodhi.server.Session.remove'):
-            app = BodhiTestApp(main({}, testing=u'bodhi', session=self.db, **self.app_settings))
+            app = TestApp(main({}, testing=u'bodhi', session=self.db, **self.app_settings))
         update_json = self.get_update(u'bodhi-2.1-1.fc17')
         update_json['csrf_token'] = self.get_csrf_token(app)
 
@@ -191,7 +192,7 @@ def test_provenpackager_privs(self, publish, *args):
         user.groups.append(group)
 
         with mock.patch('bodhi.server.Session.remove'):
-            app = BodhiTestApp(main({}, testing=u'bodhi', session=self.db, **self.app_settings))
+            app = TestApp(main({}, testing=u'bodhi', session=self.db, **self.app_settings))
         update = self.get_update(u'bodhi-2.1-1.fc17')
         update['csrf_token'] = app.get('/csrf').json_body['csrf_token']
         res = app.post_json('/updates/', update)
@@ -834,7 +835,7 @@ def test_edit_without_permission(self):
         Test a logged in User without permissions on the update can't see the form
         """
         with mock.patch('bodhi.server.Session.remove'):
-            app = BodhiTestApp(main({}, testing=u'anonymous', session=self.db, **self.app_settings))
+            app = TestApp(main({}, testing=u'anonymous', session=self.db, **self.app_settings))
 
         resp = app.get(
             '/updates/FEDORA-{}-a3bbe1a8f2/edit'.format(datetime.utcnow().year), status=400,
@@ -851,7 +852,7 @@ def test_edit_not_loggedin(self):
             'authtkt.secret': 'whatever',
             'authtkt.secure': True,
         })
-        app = BodhiTestApp(main({}, session=self.db, **anonymous_settings))
+        app = TestApp(main({}, session=self.db, **anonymous_settings))
         resp = app.get('/updates/FEDORA-2017-a3bbe1a8f2/edit', status=403,
                        headers={'accept': 'text/html'})
         self.assertIn('<h1>403 <small>Forbidden</small></h1>', resp)
@@ -1039,7 +1040,7 @@ def test_provenpackager_edit_anything(self, publish, *args):
         user2.groups.append(group2)
 
         with mock.patch('bodhi.server.Session.remove'):
-            app = BodhiTestApp(main({}, testing=u'ralph', session=self.db, **self.app_settings))
+            app = TestApp(main({}, testing=u'ralph', session=self.db, **self.app_settings))
         up_data = self.get_update(nvr)
         up_data['csrf_token'] = app.get('/csrf').json_body['csrf_token']
         res = app.post_json('/updates/', up_data)
@@ -1048,7 +1049,7 @@ def test_provenpackager_edit_anything(self, publish, *args):
             topic='update.request.testing', msg=mock.ANY)
 
         with mock.patch('bodhi.server.Session.remove'):
-            app = BodhiTestApp(main({}, testing=u'lloyd', session=self.db, **self.app_settings))
+            app = TestApp(main({}, testing=u'lloyd', session=self.db, **self.app_settings))
         update = self.get_update(nvr)
         update['csrf_token'] = app.get('/csrf').json_body['csrf_token']
         update['notes'] = u'testing!!!'
@@ -1077,7 +1078,7 @@ def test_provenpackager_request_privs(self, publish, *args):
         user2.groups.append(group2)
 
         with mock.patch('bodhi.server.Session.remove'):
-            app = BodhiTestApp(main({}, testing=u'ralph', session=self.db, **self.app_settings))
+            app = TestApp(main({}, testing=u'ralph', session=self.db, **self.app_settings))
         up_data = self.get_update(nvr)
         up_data['csrf_token'] = app.get('/csrf').json_body['csrf_token']
         res = app.post_json('/updates/', up_data)
@@ -1091,7 +1092,7 @@ def test_provenpackager_request_privs(self, publish, *args):
 
         # Try and submit the update to stable as a non-provenpackager
         with mock.patch('bodhi.server.Session.remove'):
-            app = BodhiTestApp(main({}, testing=u'ralph', session=self.db, **self.app_settings))
+            app = TestApp(main({}, testing=u'ralph', session=self.db, **self.app_settings))
         post_data = dict(update=nvr, request='stable',
                          csrf_token=app.get('/csrf').json_body['csrf_token'])
         res = app.post_json('/updates/%s/request' % str(nvr), post_data, status=400)
@@ -1131,7 +1132,7 @@ def test_provenpackager_request_privs(self, publish, *args):
 
         # Try and submit the update to stable as a proventester
         with mock.patch('bodhi.server.Session.remove'):
-            app = BodhiTestApp(main({}, testing=u'bob', session=self.db, **self.app_settings))
+            app = TestApp(main({}, testing=u'bob', session=self.db, **self.app_settings))
 
         res = app.post_json('/updates/%s/request' % str(nvr),
                             dict(update=nvr, request='stable',
@@ -1141,7 +1142,7 @@ def test_provenpackager_request_privs(self, publish, *args):
         self.assertEqual(res.json_body['update']['request'], 'stable')
 
         with mock.patch('bodhi.server.Session.remove'):
-            app = BodhiTestApp(main({}, testing=u'bob', session=self.db, **self.app_settings))
+            app = TestApp(main({}, testing=u'bob', session=self.db, **self.app_settings))
 
         res = app.post_json('/updates/%s/request' % str(nvr),
                             dict(update=nvr, request='obsolete',
@@ -1157,14 +1158,14 @@ def test_provenpackager_request_privs(self, publish, *args):
 
         # Test that bob has can_edit True, provenpackager
         with mock.patch('bodhi.server.Session.remove'):
-            app = BodhiTestApp(main({}, testing=u'bob', session=self.db, **self.app_settings))
+            app = TestApp(main({}, testing=u'bob', session=self.db, **self.app_settings))
 
         res = app.get('/updates/%s' % str(nvr), status=200)
         self.assertEqual(res.json_body['can_edit'], True)
 
         # Test that ralph has can_edit True, they submitted it.
         with mock.patch('bodhi.server.Session.remove'):
-            app = BodhiTestApp(main({}, testing=u'ralph', session=self.db, **self.app_settings))
+            app = TestApp(main({}, testing=u'ralph', session=self.db, **self.app_settings))
 
         res = app.get('/updates/%s' % str(nvr), status=200)
         self.assertEqual(res.json_body['can_edit'], True)
@@ -1172,7 +1173,7 @@ def test_provenpackager_request_privs(self, publish, *args):
         # Test that someuser has can_edit False, they are unrelated
         # This check *failed* with the old acls code.
         with mock.patch('bodhi.server.Session.remove'):
-            app = BodhiTestApp(main({}, testing=u'someuser', session=self.db, **self.app_settings))
+            app = TestApp(main({}, testing=u'someuser', session=self.db, **self.app_settings))
 
         res = app.get('/updates/%s' % str(nvr), status=200)
         self.assertEqual(res.json_body['can_edit'], False)
@@ -1186,7 +1187,7 @@ def test_provenpackager_request_privs(self, publish, *args):
         })
 
         with mock.patch('bodhi.server.Session.remove'):
-            app = BodhiTestApp(main({}, session=self.db, **anonymous_settings))
+            app = TestApp(main({}, session=self.db, **anonymous_settings))
 
         res = app.get('/updates/%s' % str(nvr), status=200)
         self.assertEqual(res.json_body['can_edit'], False)
@@ -5804,7 +5805,7 @@ def test_get_test_results_calling_greenwave_no_session(self, call_api, *args):
         call_api.return_value = {"foo": "bar"}
 
         with mock.patch('bodhi.server.Session.remove'):
-            app = BodhiTestApp(main(
+            app = TestApp(main(
                 {}, testing=u'bodhi', session=self.db,
                 greenwave_api_url='https://greenwave.api', **self.app_settings))
             res = app.get('/updates/%s/get-test-results' % str(nvr))
@@ -5843,7 +5844,7 @@ def test_get_test_results_calling_greenwave_unauth(self, call_api, *args):
             'greenwave_api_url': 'https://greenwave.api',
         })
         # with mock.patch('bodhi.server.Session.remove'):
-        app = BodhiTestApp(main({}, session=self.db, **anonymous_settings))
+        app = TestApp(main({}, session=self.db, **anonymous_settings))
         res = app.get('/updates/%s/get-test-results' % nvr, status=404)
 
         self.assertEqual(
diff --git a/bodhi/tests/server/test_renderers.py b/bodhi/tests/server/test_renderers.py
--- a/bodhi/tests/server/test_renderers.py
+++ b/bodhi/tests/server/test_renderers.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-# Copyright © 2017 Red Hat, Inc.
+# Copyright © 2018 Red Hat, Inc.
 #
 # This file is part of Bodhi.
 #
@@ -23,6 +23,7 @@
 import re
 
 import PIL.Image
+import webtest
 from six import BytesIO
 
 from bodhi.server import main
@@ -50,7 +51,7 @@ def test_renderer_jpeg(self):
             'captcha.ttl': '300',
         })
         with mock.patch('bodhi.server.Session.remove'):
-            app = base.BodhiTestApp(main({}, session=self.db, **settings))
+            app = webtest.TestApp(main({}, session=self.db, **settings))
 
         res = app.get('/updates/FEDORA-{}-a3bbe1a8f2'.format(datetime.datetime.utcnow().year),
                       status=200,
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
@@ -21,6 +21,7 @@
 import copy
 
 import mock
+import webtest
 
 from bodhi.server import main, util
 from bodhi.server.models import (
@@ -363,7 +364,7 @@ def test_popup_toggle(self):
             'authtkt.secret': 'whatever',
             'authtkt.secure': True,
         })
-        app = base.BodhiTestApp(main({}, session=self.db, **anonymous_settings))
+        app = webtest.TestApp(main({}, session=self.db, **anonymous_settings))
         res = app.post('/popup_toggle', status=403)
         self.assertIn('<h1>403 <small>Forbidden</small></h1>', res)
         self.assertIn('<p class="lead">Access was denied to this resource.</p>', res)
@@ -381,7 +382,7 @@ def test_new_override_form(self):
             'authtkt.secret': 'whatever',
             'authtkt.secure': True,
         })
-        app = base.BodhiTestApp(main({}, session=self.db, **anonymous_settings))
+        app = webtest.TestApp(main({}, session=self.db, **anonymous_settings))
         res = app.get('/overrides/new', status=403)
         self.assertIn('<h1>403 <small>Forbidden</small></h1>', res)
         self.assertIn('<p class="lead">Access was denied to this resource.</p>', res)
@@ -399,7 +400,7 @@ def test_new_update_form(self):
             'authtkt.secret': 'whatever',
             'authtkt.secure': True,
         })
-        app = base.BodhiTestApp(main({}, session=self.db, **anonymous_settings))
+        app = webtest.TestApp(main({}, session=self.db, **anonymous_settings))
         res = app.get('/updates/new', status=403)
         self.assertIn('<h1>403 <small>Forbidden</small></h1>', res)
         self.assertIn('<p class="lead">Access was denied to this resource.</p>', res)